Pylon and Plain are genuinely good. They are API-first, developer-friendly, and built for the Slack-native support workflows that modern engineering teams actually use. The migration decision is usually straightforward. Moving the UI is the easy part.
The hard part is that you have five years of relational ticket history sitting in Zendesk. Tens of thousands of tickets. Custom fields mapped to anonymous numeric IDs. Sunshine Custom Objects holding hardware asset records, license entitlements, and product-specific metadata that exist nowhere else. And a deprecation deadline that most teams don't even know about yet.
This guide is a field manual for getting that data out cleanly before it becomes someone else's problem — or disappears entirely. For the broader help desk migration failure modes that affect every platform exit — not just Pylon and Plain — see our migration field guide.
The Ticking Clock: Sunshine API Deprecation — July 1, 2026
Before we get into architecture, a hard deadline you need to put on your calendar today.
Zendesk is deprecating the Sunshine Custom Objects API on July 1, 2026.
If your Zendesk instance has Custom Objects — and if you have more than 200 agents or run any kind of product-led support, it almost certainly does — you have a fixed window to extract that data. After July 1st, the API endpoints return 410 Gone. The data is not archived. It is not moved somewhere. It is gone.
Custom Objects in Zendesk are the most underestimated part of any migration. Standard ticket exports skip them entirely. See the Sunshine Custom Objects sunset timeline and extraction guide for the full picture. Most teams don't even know what's stored there until they query the API and find:
- Hardware asset inventories linked to tickets via
lookupfields - License entitlement records with renewal dates and seat counts
- Product-specific SLA configurations that override defaults
- B2B account hierarchies that don't map to the flat Organization model
These records have foreign key relationships back into your ticket and user tables. Lose them, and you lose the relational context that makes your support history useful for compliance, product analytics, and customer health scoring.
The deadline is not a soft sunset. It is a destruction event. Plan accordingly.
Why the Tools You Already Have Will Fail You
The Native Zendesk CSV Export
The first tool every team reaches for is the one Zendesk provides: the native CSV export from the Admin Center. For a Shopify store with 500 tickets, it is fine. For a Series B SaaS company with 5 years of enterprise support history, it is a trap with three specific failure modes.
Failure Mode 1: via.channel flattening. Every ticket event in Zendesk has a via object that describes how it was created — web, api, email, internal_note, side_conversation. The CSV exporter collapses this into a single channel value at the ticket level. You lose the ability to distinguish internal agent notes from public customer replies. If you are migrating to Pylon for a B2B account — where the audit trail of internal discussion matters — this data is not recoverable from the CSV.
Failure Mode 2: Ticket events, not ticket history. The CSV gives you ticket headers: ID, subject, status, assignee, created_at, updated_at. It does not give you the full comment thread — the sequential Ticket Comments resource that represents the actual conversation. You get a snapshot of the ticket's state, not the history of how it got there.
Failure Mode 3: Custom Objects are not exported. The Sunshine Custom Objects API is a separate surface entirely. The native export has no concept of it. You will get a clean-looking CSV and assume your migration is complete, and you will be wrong.
Fivetran and Airbyte
The second tool teams reach for is a managed ETL connector. Both Fivetran and Airbyte have Zendesk connectors. Both will fail you in a migration context for the same structural reason.
These tools are built for ongoing sync, not exit extraction. Their economic model is Monthly Active Records — they bill per row synced per month, and their connectors are optimized for incremental, low-volume delta loads against a fixed schema. When you run an exit migration, you are doing a full historical load against a schema that is, in Zendesk's case, partially dynamic.
The silent failure happens at Custom Objects. Zendesk's Custom Object schemas are defined per-instance. There is no canonical schema. When Fivetran's connector encounters a Custom Object type it hasn't seen before, it skips it — no error, no warning, no log entry. You only discover the gap when you audit your target database weeks later and notice the hardware_assets table doesn't exist.
The DIY Python Script: A Two-Week Sprint
Assume you've ruled out the CSV and the managed ETL. Now an engineer on your team says: "I'll just write a script." Here is what that script actually costs.
Day 1: The Naive Pagination Loop
The first version of the script is straightforward. Hit /api/v2/tickets.json, get 100 tickets per page, loop through the next_page cursor, write to a JSON file. This works for the first 10,000 tickets.
Then you hit the first real problem: Zendesk uses two completely different pagination strategies across its API surfaces.
- Time-based incremental pagination for tickets (the
Incremental Ticket Exportendpoint usingstart_timeandend_time) - Cursor-based pagination for most other resources, including Organization Memberships, Group Memberships, and Ticket Audits
These are not interchangeable. The cursor from a tickets endpoint does not work with a memberships endpoint. You need to implement and maintain two separate pagination state machines, and they interact in ways that are not documented. For the full breakdown of Zendesk API extraction edge cases — including the 429 ghost loop and dual-pagination recovery — see our dedicated deep dive.
Day 3: The 429 Ghost Loop
Zendesk enforces rate limits through a combination of X-RateLimit-Remaining and Retry-After headers. The limit for most enterprise plans is 700 requests per minute. For a large instance, you will hit this immediately.
The naive approach — catch a 429, sleep for Retry-After seconds, retry — works until it doesn't. The failure mode is a ghost loop: you successfully sleep and retry, the request succeeds, but the response contains a cursor pointing to a page you have already processed. Zendesk's rate limit recovery is not atomic. Duplicate records appear silently in your output, and the only way to detect them is to run a deduplication pass after the fact — which requires holding the entire output in memory or writing a second-pass script.
A production-grade implementation needs exponential backoff with jitter, request-level deduplication keyed on record IDs, and a checkpoint mechanism that persists pagination state to disk so you can resume without starting over if the script is interrupted.
Day 7: The Sunshine Custom Objects Discovery Problem
Custom Objects do not have a fixed endpoint. You first need to hit /api/v2/custom_objects to discover what types exist in your instance. Then for each type, you need to hit /api/v2/custom_objects/{key}/records to extract the records. Then for each record, you need to resolve lookup field values — which are foreign keys back into tickets, users, or organizations — to reconstruct the relational links.
The schemas are per-instance and undocumented. Field names are whatever the Zendesk admin chose when they created the object. Values may be IDs, human-readable strings, or opaque references to other custom objects. Writing a general-purpose extraction layer for this requires dynamic schema discovery, which requires yet another set of API calls, which are subject to rate limits, which requires more backoff logic.
By this point, the "2-day script" is two weeks deep, has three engineers involved, and is still producing output that requires manual validation.
The Ghost Seat: The Real Cost of Migration Failure
When teams run out of time — and they always do — they fall back to the Ghost Seat strategy. They complete the migration to Pylon or Plain for active tickets and current workflows, but they keep a Zendesk account alive at the lowest available tier specifically to retain read access to historical data.
The typical cost is $19–$49 per agent per month for a single "legacy access" seat. Teams tell themselves it's temporary. In practice, Ghost Seats last years. The historical data becomes the technical debt that nobody wants to touch. Compliance requests trigger a manual search through the old Zendesk interface. Product analytics that could use historical support signals just don't get built.
At $49/month, a Ghost Seat costs $588/year. Over three years, that is $1,764 — and you still don't have the data in a queryable format.
The Clean Architecture: What a Correct Zendesk Extraction Looks Like
Before introducing Evicta specifically, here is what a correct extraction architecture produces. The target schema for a Zendesk-to-PostgreSQL migration should look like this:
-- Core ticket table with channel provenance preserved
CREATE TABLE tickets (
id BIGINT PRIMARY KEY,
external_id TEXT,
subject TEXT,
status TEXT NOT NULL, -- open, pending, solved, closed
priority TEXT, -- low, normal, high, urgent
ticket_type TEXT,
via_channel TEXT NOT NULL, -- email, web, api, voice, chat
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
solved_at TIMESTAMPTZ,
requester_id BIGINT REFERENCES users(id),
submitter_id BIGINT REFERENCES users(id),
assignee_id BIGINT REFERENCES users(id),
organization_id BIGINT REFERENCES organizations(id),
group_id BIGINT REFERENCES groups(id),
brand_id BIGINT,
satisfaction_rating TEXT,
tags TEXT[]
);
-- Full comment thread with internal/public distinction preserved
CREATE TABLE ticket_comments (
id BIGINT PRIMARY KEY,
ticket_id BIGINT NOT NULL REFERENCES tickets(id),
author_id BIGINT REFERENCES users(id),
body TEXT,
html_body TEXT,
public BOOLEAN NOT NULL, -- FALSE = internal note
via_channel TEXT NOT NULL, -- source of this specific comment
created_at TIMESTAMPTZ NOT NULL
);
-- Sunshine Custom Objects — dynamically discovered and typed
CREATE TABLE custom_object_records (
id TEXT PRIMARY KEY,
object_type TEXT NOT NULL, -- e.g., 'hardware_asset', 'license_entitlement'
name TEXT,
external_id TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
attributes JSONB NOT NULL -- full field map, schema varies per type
);
-- Foreign key relationships from custom objects back to core entities
CREATE TABLE custom_object_relationships (
id TEXT PRIMARY KEY,
relationship_type TEXT NOT NULL,
source_type TEXT NOT NULL,
source_id TEXT NOT NULL,
target_type TEXT NOT NULL, -- 'ticket', 'user', 'organization'
target_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
-- Human-readable custom field mapping (the missing layer in CSV exports)
CREATE TABLE ticket_custom_fields (
ticket_id BIGINT NOT NULL REFERENCES tickets(id),
field_id BIGINT NOT NULL,
field_key TEXT NOT NULL, -- e.g., 'refund_reason', 'product_sku'
field_title TEXT NOT NULL, -- human-readable label
value TEXT,
PRIMARY KEY (ticket_id, field_id)
);
The key column is public BOOLEAN NOT NULL on ticket_comments. That single boolean — derived from via.channel in the Zendesk API response — is what the CSV export destroys. It is the difference between a queryable audit trail and a flat chronological dump.
Why Evicta Exists
Evicta is a surgical extraction layer built specifically for this problem. Not a general-purpose ETL, not a sync subscription. A one-time, auditable extraction job that handles all of the failure modes described above.
Zero-Persistence Architecture
No bytes of your Zendesk data touch Evicta's persistent infrastructure. When you start an extraction job, a scoped ephemeral vault is provisioned in Cloudflare R2, encrypted with a job-specific key. The Zendesk API responses stream directly into the vault. When the job completes, the vault is transferred to you — your S3 bucket, your GCS bucket, or a direct download — and the ephemeral copy is purged. We have no access to your data after transfer and no incentive to retain it.
LLM-Assisted Schema Mapping
The hardest part of a Zendesk extraction is not the data volume. It is the opaque IDs. Custom fields are identified by numeric IDs in the API. Custom Object attributes have machine-generated keys. Lookup field values are foreign key IDs with no inline label.
Evicta uses an LLM-assisted schema discovery pass before extraction begins. It queries your instance's Custom Fields API, Custom Object Types API, and Ticket Field API to build a complete field map. It then generates human-readable column names, infers data types, and produces the CREATE TABLE DDL for your target schema before a single ticket is extracted. You review the mapping. You approve it. Then the extraction runs against a schema you understand.
The Math
A 2-week engineering sprint at a fully-loaded cost of $10,000–$15,000 for a senior engineer's time. A Ghost Seat at $49/month kept for three years: $1,764. A failed ETL migration that requires a re-run: multiply the engineering cost by 1.5x for the remediation work.
Evicta's flat fee is $1,499. One extraction, one clean schema, zero recurring dependency on a dead support tool.
The Migration Sequence for Pylon and Plain
For teams migrating specifically to Pylon or Plain, the recommended sequence is:
-
Audit your Custom Object types now. Hit
GET /api/v2/custom_objectsagainst your Zendesk instance and catalog what's there. Do this before you do anything else. If you have custom objects and you are reading this after June 2026, stop reading and call someone immediately. -
Extract before you migrate UI workflows. Do not switch your agents to Pylon first. Extract the historical data while you still have full Zendesk API access under your current plan. Downgrading or canceling changes your API rate limit tier, which slows extraction significantly.
-
Preserve the via.channel provenance. When loading ticket history into Plain, use Plain's
threadAPI to reconstruct conversation threads. Mappublic: falseZendesk comments to Plain'sinternal notethread type. This is the context your agents will need when a customer re-opens a historical issue. -
Resolve custom object relationships before import. Your custom object records need to be loaded into your target database before you import tickets, because tickets have foreign key dependencies on them. Extract and resolve the relationship graph first.
-
Validate row counts at each layer. Before decommissioning Zendesk, query your target database: ticket count, comment count, attachment count, custom object record count. Query the Zendesk API for the same counts via the
Countendpoints. If the numbers don't match within a 0.1% margin, do not proceed.
The Bottom Line
Moving to Pylon or Plain is the right decision for most modern engineering-led support organizations. The API-first architecture, the Slack-native workflows, the developer experience — it is genuinely better than Zendesk.
The data migration is a separate, technical problem that does not get solved by choosing a good destination platform. It requires handling rate limits correctly, managing dual pagination strategies, resolving opaque field IDs, and extracting Custom Objects before the July 1, 2026 destruction event.
The teams that do this well come out with a clean PostgreSQL schema they can actually query. The teams that don't end up with a Ghost Seat, an incomplete migration, and a compliance liability.