Skip to content

Salesforce Service Cloud to Zendesk Migration: Technical Guide

Technical guide to migrating from Salesforce Service Cloud to Zendesk. Covers data model mapping, SOQL extraction, Ticket Import API, attachments, and common failure modes.

Wahab Wahab · · 21 min read
Salesforce Service Cloud to Zendesk Migration: 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

Salesforce Service Cloud to Zendesk Migration: Technical Guide

Migrating from Salesforce Service Cloud to Zendesk is an architectural shift — from an enterprise CRM-and-service platform with a deep relational data model to a focused, hierarchical helpdesk built around the ticket lifecycle. Cases become Tickets. Contacts become Users. Accounts become Organizations. And most of the Salesforce data model — EmailMessage, CaseComment, ContentVersion, Entitlements, custom objects, multi-level Account hierarchies — must be translated, flattened, or left behind.

There is no native migration path between these platforms. Salesforce splits customer interactions across Case, CaseComment, EmailMessage, and LiveChatTranscript objects. Zendesk expects all of these interactions flattened into a single, chronological comments array within a Ticket object. If you approach this as a basic CSV import, you will lose historical timestamps, break file attachments, and trigger thousands of unintended email notifications to your customers.

The viable route is: Salesforce Bulk API 2.0 extraction → data transformation → Zendesk Ticket Import API loading. This guide covers the exact object mapping, SOQL extraction methods, transformation logic with working code, API constraints on both sides, and the failure modes that appear in real migrations. For the reverse direction, see our Zendesk to Salesforce Service Cloud migration guide. For a high-level platform comparison, see our Zendesk vs Salesforce Service Cloud architecture guide.

All API-specific claims reflect documentation as of mid-2026 — verify rate limits and field constraints against current platform docs before implementation.

Why Teams Move from Salesforce Service Cloud to Zendesk

The trigger is almost always platform-fit economics combined with operational simplicity. Salesforce Service Cloud is powerful and deeply customizable — but that power carries proportional licensing cost, admin overhead, and configuration complexity.

Common triggers:

  • Cost reduction. Salesforce Service Cloud Enterprise lists at $165/user/month; Unlimited at $330/user/month. Most mid-market teams run Enterprise or above because lower tiers lack omni-channel routing and advanced case management. Zendesk Suite Team starts at $55/agent/month and Suite Professional at $115/agent/month. For a 30-agent team, the annual licensing gap between Salesforce Enterprise and Zendesk Suite Professional alone is roughly $18,000/year — before factoring in Salesforce add-ons (Digital Engagement at $75/user/month, Service Cloud Voice billed separately) and admin overhead.
  • Reduced admin burden. Salesforce requires dedicated admin resources for Flows, validation rules, page layouts, Record Types, and permission sets. Zendesk's trigger/automation model is simpler by design — a support ops lead can typically manage it without a dedicated admin.
  • Faster agent onboarding. Agents working inside the full Salesforce UI face steeper learning curves. Zendesk's Agent Workspace is narrower in scope, which makes ramp-up faster for teams that don't need CRM-depth visibility.
  • Decoupling support from CRM. Teams where Sales Cloud and Service Cloud share an org sometimes find that support-specific changes are blocked by Sales Cloud dependencies (shared validation rules, shared custom objects, release management conflicts). Moving support to Zendesk eliminates that coupling.
Info

This migration makes sense when support operations don't need direct access to Opportunities, custom Account hierarchies, or deep CRM reporting. If your support team regularly references pipeline data or Entitlement processes with milestone tracking, Zendesk may not be the right target.

Salesforce Service Cloud to Zendesk: Data Model Mapping

Salesforce Service Cloud has a deep, branching object graph. Zendesk is flatter. Before writing any extraction scripts, map the two models:

Salesforce Object Zendesk Object Notes
Case Ticket Core 1:1 mapping. Record Types, Entitlements, and parent-child Case hierarchies have no Zendesk equivalent.
Contact User (end-user) Email is the unique identifier on both sides. Deduplicate Contacts with shared emails before import — Zendesk rejects duplicates.
Account Organization Zendesk Organizations are flat — no parent-child Account hierarchies.
User (agent) Agent Salesforce User profiles and Permission Sets don't transfer. Map agents by email. Agents must be migrated first — Zendesk requires their IDs for ticket assignment and comment authorship.
EmailMessage (Incoming=true) Public comment Public customer replies on Cases.
EmailMessage (Incoming=false) Public comment Agent replies sent to the customer.
CaseComment (IsPublished=false) Private comment (internal note) Internal-only notes.
CaseComment (IsPublished=true) Public comment Published comments visible to the requester.
ContentVersion / Attachment Attachment (via upload token) Each file requires a separate upload call before linking to a comment.
Knowledge Article (KnowledgeArticleVersion) Help Center Article (Guide) Sections, categories, and article metadata must be manually recreated.
CaseHistory No direct equivalent Audit trail data. Can be appended as a private note or stored externally.
FeedItem (Chatter) No direct equivalent Chatter posts on Cases have no Zendesk analog. Export to a private note if needed.
Entitlement / Milestone SLA Policy Zendesk SLAs are defined by conditions (priority, org, tags), not per-contract entitlement records. Rebuild from scratch.
Queue Group Salesforce Queues map to Zendesk Groups, but queue-based assignment rules must be rebuilt as Zendesk triggers.

What Doesn't Transfer

  • Parent-child Case relationships. Salesforce supports hierarchical Cases (ParentId). Zendesk tickets are flat. Encode the relationship in a tag or custom field.
  • Record Types. Salesforce uses Record Types to control page layouts and picklist values per Case type. In Zendesk, use Ticket Forms for similar segmentation.
  • Account hierarchies. Salesforce Account records can nest (Ultimate Parent → Parent → Child). Zendesk Organizations are single-level. Flatten before import or use Organization Fields to preserve the hierarchy as metadata.
  • Entitlement Processes and Milestones. Deeply Salesforce-specific. Zendesk SLA policies operate on different logic (condition-based, not contract-linked). Rebuild manually.
  • Custom objects. Any custom objects related to Cases (e.g., custom Product, Asset, or escalation objects) have no Zendesk home unless you use Zendesk Sunshine Custom Objects, which require schema design work upfront — each custom object type needs a defined schema, and relationships between Sunshine objects and tickets are expressed via lookup fields, not foreign keys.
  • Custom status models. Salesforce allows highly customized status models. Zendesk enforces a strict system status set: New, Open, Pending, On-hold, Solved, Closed. Custom Salesforce statuses like "Escalated to Engineering" or "Waiting on Vendor" must be mapped to a standard Zendesk status and preserved via a custom dropdown field. Zendesk's custom ticket statuses feature (available on Suite Enterprise and above) allows additional statuses within the Open or Pending categories, but the Import API status field still requires one of the six system values — custom statuses are applied via a separate custom_status_id field and must be pre-created via the Custom Ticket Statuses API before import.

Step-by-Step Migration Process

Step 1: Audit and Scope the Salesforce Data

Before writing any extraction code, inventory what you're migrating. Use SOQL to get exact counts:

SELECT COUNT() FROM Case WHERE CreatedDate > 2022-01-01T00:00:00Z
SELECT COUNT() FROM EmailMessage WHERE ParentId IN (SELECT Id FROM Case WHERE CreatedDate > 2022-01-01T00:00:00Z)
SELECT COUNT() FROM CaseComment WHERE ParentId IN (SELECT Id FROM Case WHERE CreatedDate > 2022-01-01T00:00:00Z)
SELECT COUNT() FROM ContentDocumentLink WHERE LinkedEntityId IN (SELECT Id FROM Case WHERE CreatedDate > 2022-01-01T00:00:00Z)

Check for soft-deleted records. Salesforce moves deleted records to the Recycle Bin rather than hard-deleting them. The standard SOQL above silently excludes them. If you need to include (or explicitly exclude) deleted Cases, use the ALL ROWS keyword:

SELECT COUNT() FROM Case WHERE CreatedDate > 2022-01-01T00:00:00Z ALL ROWS

Compare the two counts. If the delta is significant, decide whether to restore, migrate, or discard soft-deleted Cases. Once you proceed past extraction, deleted records become inaccessible without a data recovery request.

Decide the migration scope: all historical Cases, or a date-bounded subset. Migrating everything sounds safe but adds cost and time. Most teams migrate 2–3 years of history and archive the rest.

Check for custom fields on Case, Contact, and Account. Every custom field needs a corresponding Zendesk custom field created before import. Salesforce picklist values must be pre-populated in Zendesk dropdown fields — Zendesk rejects values that don't match. Run a distinct-values query for every picklist field:

SELECT Priority, COUNT(Id) FROM Case GROUP BY Priority
SELECT Status, COUNT(Id) FROM Case GROUP BY Status

For a detailed pre-migration audit framework, see our Salesforce Service Cloud Migration Checklist.

Step 2: Extract Data from Salesforce

Prerequisites: Salesforce Connected App. Before running Bulk API 2.0 queries, you need a Connected App configured in Salesforce Setup with these OAuth scopes: api, refresh_token, offline_access, and Perform requests on your behalf at any time. For Bulk API specifically, the api scope is sufficient — the Bulk API runs under the authenticating user's permissions, so that user also needs the "API Enabled" and "Bulk API Hard Delete" profile permissions if you plan to include soft-deleted records.

Do not use Salesforce's native Data Export tool for a full migration. The weekly export provides fragmented CSVs that are difficult to join, especially for attachments and rich text fields. Use Salesforce Bulk API 2.0 to run specific SOQL queries.

Warning

Salesforce Bulk API is capped at 15,000 batch submissions per 24-hour rolling window, with each batch holding up to 10,000 records. For most migrations this is not a bottleneck, but if you're extracting millions of records across multiple objects, plan your extraction sequence to stay within daily limits.

Export in dependency order:

  1. Accounts — these become Organizations
  2. Contacts — these become end-users, linked to Organizations
  3. Users (agents) — needed for author/assignee mapping
  4. Cases — the core records
  5. EmailMessage — linked to Cases via ParentId
  6. CaseComment — linked to Cases via ParentId
  7. ContentDocumentLink → ContentVersion — the attachment chain
  8. Knowledge articles (if migrating Help Center content)

Extracting Interactions

The hardest part of a Salesforce extraction is reconstructing the conversation history. Salesforce isolates emails and internal notes into separate objects. Extract both:

-- Emails
SELECT Id, ParentId, MessageDate, FromAddress, ToAddress, TextBody, HtmlBody, Incoming 
FROM EmailMessage 
WHERE ParentId != null
 
-- Internal Notes
SELECT Id, ParentId, CreatedDate, CreatedById, CommentBody, IsPublished 
FROM CaseComment

Important difference: EmailMessage has both TextBody and HtmlBody fields. CaseComment has only CommentBody (plain text) — there is no HTML variant. This distinction matters during transformation.

Extracting Attachments

Salesforce has transitioned from the legacy Attachment object to Salesforce Files (ContentVersion and ContentDocumentLink). Older orgs may still have files on both — check both objects.

SELECT ContentDocumentId, LinkedEntityId 
FROM ContentDocumentLink 
WHERE LinkedEntityId IN (SELECT Id FROM Case)

Download the actual file binaries via the REST API endpoint /services/data/vXX.X/sobjects/ContentVersion/{Id}/VersionData. Note that Salesforce's REST API has an undocumented practical limit on response size for VersionData calls — very large files (typically above 100MB) can cause timeout errors on the REST endpoint. For files approaching this threshold, use the Accept-Encoding: gzip header to reduce payload size, or implement chunked download logic using HTTP Range headers. Store binaries locally or in an S3 bucket for the transformation phase.

Step 3: Prepare the Zendesk Instance

Test against a Sandbox first. Before touching your production Zendesk environment, run the complete import pipeline against a Zendesk Sandbox. Zendesk provides a full-featured Sandbox on Suite Professional and above. This lets you validate field mappings, comment ordering, attachment handling, and trigger suppression without risking production data. Only promote to production after a clean Sandbox run.

Before loading any data into production:

  1. Create Organizations from extracted Account data using the Organizations API or CSV bulk import. Map the Salesforce Account ID to a custom external_id field in Zendesk for cross-referencing.
  2. Create Users (both end-users and agents). Match on email address. Zendesk's bulk user import supports up to 2,000 users at a time via CSV in the admin interface, or 100 per API request.
  3. Create custom fields matching every Salesforce custom field you're migrating. Note the Zendesk field IDs — you'll need them for the import payload. Pre-populate all dropdown options before import — Zendesk rejects values that don't exist in the field definition.
  4. Create Ticket Forms if you're using multiple Salesforce Record Types.
  5. Disable all Triggers, Automations, and SLA policies. Non-negotiable.
Danger

If you forget to disable triggers before import, Zendesk will fire email notifications to every customer whose ticket gets touched. There is no undo for sent emails. Disable all triggers first, verify they're off, then proceed.

Step 4: Transform the Data

This is where most of the engineering work lives. The transformation layer merges three Salesforce objects (EmailMessage, CaseComment, and their attachments) into Zendesk's flat comments array — sorted chronologically, with correct authorship, privacy flags, and HTML formatting.

Building the ID Mapping Table

Salesforce IDs (15- or 18-character) don't exist in Zendesk. For every Contact, Account, and User you create in Zendesk, capture the mapping:

import json
 
def build_id_map(salesforce_records, zendesk_created_records):
    """
    salesforce_records: list of dicts with 'Id' key from SOQL export
    zendesk_created_records: list of dicts with 'id' and 'external_id' from Zendesk API response
    Returns: dict mapping Salesforce ID -> Zendesk ID
    """
    id_map = {}
    zendesk_by_external = {r['external_id']: r['id'] for r in zendesk_created_records}
    for sf_record in salesforce_records:
        sf_id = sf_record['Id']
        if sf_id in zendesk_by_external:
            id_map[sf_id] = zendesk_by_external[sf_id]
    return id_map
 
# Save to disk — you'll need this across multiple script runs
with open('id_map_users.json', 'w') as f:
    json.dump(user_id_map, f)

Write this map to disk. If your import pipeline fails partway through and you need to restart, you can reload the map without re-creating already-migrated records.

Merging EmailMessage and CaseComment into Comments

This is the core transformation. Union both datasets, sort by timestamp, and build the Zendesk comments array:

from datetime import datetime, timezone
 
def merge_case_interactions(email_messages, case_comments, user_id_map):
    """
    email_messages: list of dicts from EmailMessage SOQL query
    case_comments: list of dicts from CaseComment SOQL query  
    user_id_map: dict mapping Salesforce User ID -> Zendesk User ID
    Returns: dict mapping Case ID -> sorted list of Zendesk comment dicts
    """
    from collections import defaultdict
    comments_by_case = defaultdict(list)
 
    # Process EmailMessage records
    for email in email_messages:
        case_id = email['ParentId']
        # Prefer HtmlBody; fall back to TextBody wrapped in <p> tags
        if email.get('HtmlBody'):
            body = email['HtmlBody']
            body_field = 'html_body'
        elif email.get('TextBody'):
            body = f"<p>{email['TextBody']}</p>"
            body_field = 'html_body'
        else:
            # Zendesk rejects empty comment bodies — use placeholder
            body = '<p>(attachment only)</p>'
            body_field = 'html_body'
 
        # Determine author: incoming = customer, outgoing = agent
        if email.get('Incoming'):
            # Look up by From email address — match against user_id_map by email
            author_sf_id = email.get('FromUserId')  # may be null for external senders
        else:
            author_sf_id = email.get('CreatedById')
 
        author_zendesk_id = user_id_map.get(author_sf_id)
 
        # Use MessageDate for EmailMessage (when it was actually sent/received)
        timestamp = email.get('MessageDate') or email.get('CreatedDate')
 
        comments_by_case[case_id].append({
            '_sort_key': timestamp,
            'author_id': author_zendesk_id,
            'created_at': timestamp,
            body_field: body,
            'public': True,  # EmailMessage is always public
            '_sf_id': email['Id'],
            '_sf_type': 'EmailMessage',
        })
 
    # Process CaseComment records
    for comment in case_comments:
        case_id = comment['ParentId']
        body = comment.get('CommentBody', '').strip()
        if not body:
            body = '(no content)'  # Zendesk rejects empty bodies
 
        author_zendesk_id = user_id_map.get(comment.get('CreatedById'))
        is_published = comment.get('IsPublished', False)
 
        comments_by_case[case_id].append({
            '_sort_key': comment['CreatedDate'],
            'author_id': author_zendesk_id,
            'created_at': comment['CreatedDate'],
            'value': body,  # CaseComment is plain text only
            'public': is_published,  # IsPublished=False -> internal note
            '_sf_id': comment['Id'],
            '_sf_type': 'CaseComment',
        })
 
    # Sort each case's comments chronologically
    for case_id in comments_by_case:
        comments_by_case[case_id].sort(key=lambda c: c['_sort_key'])
 
    return comments_by_case
 
 
def clean_comment_for_api(comment):
    """Remove internal tracking fields before sending to Zendesk API."""
    return {k: v for k, v in comment.items() if not k.startswith('_')}

Timezone note: Salesforce stores all timestamps in UTC. Zendesk's Import API expects ISO 8601 with UTC offset. Pass Salesforce timestamps through directly without conversion — they're already in the correct format (2024-03-15T09:22:00.000+0000). Normalize the format to drop milliseconds if your downstream validation requires strict ISO 8601 (2024-03-15T09:22:00Z).

Status Mapping

Map Salesforce Case statuses to Zendesk ticket statuses. Salesforce default statuses and common custom values:

STATUS_MAP = {
    # Salesforce default statuses
    'New': 'new',
    'Working': 'open',
    'Escalated': 'open',
    'Closed': 'closed',
    # Common custom statuses — adjust to your org
    'Pending Customer': 'pending',
    'Waiting on Vendor': 'on-hold',
    'Escalated to Engineering': 'open',
    'Resolved': 'solved',
}
 
def map_status(sf_status):
    mapped = STATUS_MAP.get(sf_status)
    if mapped is None:
        # Log unmapped statuses — don't silently default
        print(f"WARNING: Unmapped status '{sf_status}' — defaulting to 'open'")
        return 'open'
    return mapped

Run a distinct-values query on Case.Status before building this map so you handle every value in your data.

Step 5: Upload Attachments

Zendesk handles attachments as a two-step process:

  1. Upload the file via POST /api/v2/uploads → receive an upload token
  2. Include the token in the uploads array of the comment where the attachment belongs
import requests
import time
 
def upload_attachment(file_path, filename, zendesk_subdomain, api_token, admin_email):
    """
    Uploads a file to Zendesk and returns the upload token.
    Returns None if upload fails (e.g., file exceeds 50MB limit).
    """
    url = f"https://{zendesk_subdomain}.zendesk.com/api/v2/uploads"
    params = {'filename': filename}
    auth = (f"{admin_email}/token", api_token)
 
    file_size = os.path.getsize(file_path)
    if file_size > 50 * 1024 * 1024:  # 50MB limit
        print(f"SKIP: {filename} ({file_size / 1024 / 1024:.1f}MB) exceeds Zendesk 50MB limit")
        return None  # Caller should substitute an external link
 
    with open(file_path, 'rb') as f:
        headers = {'Content-Type': 'application/octet-stream'}
        response = requests.post(url, params=params, data=f, headers=headers, auth=auth)
 
    if response.status_code == 201:
        return response.json()['upload']['token']
    elif response.status_code == 429:
        retry_after = int(response.headers.get('Retry-After', 60))
        time.sleep(retry_after)
        return upload_attachment(file_path, filename, zendesk_subdomain, api_token, admin_email)
    else:
        print(f"ERROR: Upload failed for {filename}: {response.status_code} {response.text}")
        return None
Danger

Attachment Size Limits: Salesforce allows file sizes up to 2GB. Zendesk restricts attachments to 50MB per file. If your extraction encounters a file larger than 50MB, the Zendesk upload will fail. Your middleware must catch these cases, upload the oversized file to a secure external host (e.g., AWS S3 with a signed URL or public-read ACL), and append a download link to the ticket comment body instead.

Throughput by plan tier:

Zendesk Plan Rate Limit (req/min) Attachment upload time (50,000 files)
Suite Team 400 ~125 minutes
Suite Professional 700 ~71 minutes
Suite Enterprise 2,500 ~20 minutes

These are theoretical minimums assuming zero retries and no other concurrent API calls. Parallelize uploads across multiple API tokens if your plan supports it. Upload all attachments first, store the token-to-file mapping in a local database or JSON file, then reference tokens during ticket import.

Step 6: Import Tickets via the Ticket Import API

Never use the standard Zendesk Tickets API (/api/v2/tickets.json) for migrating historical data. The standard endpoint creates tickets with today's date, attributes all comments to the API user, and fires business rules including email notifications.

Use Zendesk's Ticket Import API (POST /api/v2/imports/tickets for single tickets, or POST /api/v2/imports/tickets/create_many for batches of up to 100). This endpoint is purpose-built for migrations:

  • Preserves historical timestampscreated_at, updated_at, solved_at all accept past dates
  • Supports multiple comments per ticket — unlike the standard Tickets API, which accepts only one comment per request
  • Does not trigger business rules — triggers and automations are suppressed for all imported tickets (not only closed ones)
  • Supports archive_immediately — recommended for imports exceeding 750,000 tickets; bypasses the normal ticket lifecycle and moves closed tickets directly to archive, reducing performance impact on the active ticket index

Error Logging

Track every import attempt with structured logging. You'll need this to identify failures, re-run subsets, and audit the final migration:

import csv
import json
 
def log_migration_result(log_file, sf_case_id, zd_ticket_id, status, error_message=None):
    """
    Append a row to the migration error/result log.
    
    Columns: sf_case_id, zd_ticket_id, status (success/failed/skipped), 
             error_message, retry_count, timestamp
    """
    with open(log_file, 'a', newline='') as f:
        writer = csv.writer(f)
        writer.writerow([
            sf_case_id,
            zd_ticket_id or '',
            status,
            error_message or '',
            datetime.now(timezone.utc).isoformat()
        ])

Minimum fields to capture per record: source ID, destination ID (if created), status (success/failed/skipped), error type, error message, retry count, timestamp. This log is your audit trail and your re-run input — failed records should be re-processable by reading the log and filtering for status=failed.

Example import payload:

{
  "ticket": {
    "subject": "Order #12345 — Shipping Issue",
    "status": "closed",
    "requester_id": 12345678,
    "assignee_id": 87654321,
    "group_id": 11223344,
    "created_at": "2024-03-15T09:22:00Z",
    "updated_at": "2024-03-17T14:05:00Z",
    "solved_at": "2024-03-17T14:05:00Z",
    "external_id": "sf_case_0019283",
    "tags": ["imported", "salesforce-migrated"],
    "custom_fields": [
      {"id": 900001, "value": "shipping"},
      {"id": 900002, "value": "salesforce_case_0019283"}
    ],
    "comments": [
      {
        "author_id": 12345678,
        "created_at": "2024-03-15T09:22:00Z",
        "html_body": "<p>My order hasn't arrived...</p>",
        "public": true
      },
      {
        "author_id": 87654321,
        "created_at": "2024-03-15T11:40:00Z",
        "value": "Checked with logistics — reshipping.",
        "public": true,
        "uploads": ["abc123token"]
      },
      {
        "author_id": 87654321,
        "created_at": "2024-03-15T11:41:00Z",
        "value": "Internal: customer is a VIP account, prioritize.",
        "public": false
      }
    ]
  }
}

Set external_id on every ticket. This is critical for re-runs. If your import fails partway through and you need to restart, the external_id lets you check whether a ticket already exists before creating a duplicate:

def get_ticket_by_external_id(external_id, zendesk_subdomain, api_token, admin_email):
    """Check if a ticket with this external_id already exists in Zendesk."""
    url = f"https://{zendesk_subdomain}.zendesk.com/api/v2/tickets"
    params = {'external_id': external_id}
    auth = (f"{admin_email}/token", api_token)
    response = requests.get(url, params=params, auth=auth)
    tickets = response.json().get('tickets', [])
    return tickets[0] if tickets else None
Warning

Zendesk metrics and SLAs are not calculated for imported tickets. Running SLA reports on imported data will produce incomplete results. Tag imported tickets (e.g., salesforce-migrated) and exclude them from SLA reports.

Step 7: Migrate Knowledge Base Content

If you're using Salesforce Knowledge, you need to migrate articles to Zendesk Guide. Extract Knowledge__kav (Knowledge Article Version) records where PublishStatus = 'Online' and load them via the Help Center API.

Salesforce's Data Categories must be translated into Zendesk's Category → Section → Article hierarchy.

Watch for:

  • Inline images stored as Salesforce ContentVersion records — these URLs will break once the Salesforce instance is deactivated. Download them, upload to Zendesk Guide as article attachments, and run a regex replace on the article body to update <img> tags with new Zendesk URLs.
  • Article visibility — Salesforce Knowledge uses data categories and user profiles for access control. Zendesk Guide uses User Segments tied to tags or organizations. Rebuild access rules manually.
  • Article versioning — Salesforce supports published, draft, and archived versions. Zendesk Guide has draft and published. Migrate only the latest published version unless you need historical drafts.

Step 8: Validate the Migration

Run validation before re-enabling any automations:

  • Record counts. Compare total Cases in Salesforce vs. total Tickets in Zendesk. Variance should be zero.
  • Comment counts per ticket. Spot-check 50+ tickets. Each ticket should have the same number of comments (EmailMessages + CaseComments combined) as the source.
  • Thread integrity. Do comments appear in the correct chronological order? Are internal notes properly marked as private?
  • Attachment accessibility. Download a sample of migrated attachments and verify they open correctly. Check for 404 errors on historical tickets.
  • Timestamp accuracy. Verify created_at and solved_at on imported tickets match the source Case data.
  • Custom field values. Run a report in Zendesk filtering by each dropdown field value to confirm all options populated correctly.
  • Agent and requester assignment. Confirm the correct agent and requester are linked to each ticket — not defaulting to the API admin user.
  • External ID integrity. Query Zendesk for tickets with no external_id — these are orphaned imports that won't be catchable on a re-run.

Only after validation passes should you re-enable triggers and automations.

Zendesk API Rate Limits and Migration Throughput

Zendesk API rate limits vary by plan and directly impact migration speed:

Zendesk Plan Rate Limit (requests/min)
Support Team 200
Suite Team 400
Suite Professional 700
Suite Enterprise 2,500

The Ticket Import API (/api/v2/imports/tickets/create_many) accepts up to 100 tickets per request. Theoretical throughput at each tier:

Plan Rate Limit Max tickets/min (at 100/request) Estimated throughput (accounting for overhead)
Suite Team 400 req/min 40,000/min ~3,000–6,000/hr
Suite Professional 700 req/min 70,000/min ~5,000–15,000/hr
Suite Enterprise 2,500 req/min 250,000/min ~20,000–50,000/hr

Effective throughput is lower than theoretical maximum because attachment uploads, user lookups, error handling, and retry delays all consume API quota. A 500,000-ticket migration at Professional-plan effective rates takes 33–100 hours of import time; at Enterprise rates, 10–25 hours.

The incremental export API (used for reading data out of Zendesk, relevant during delta sync periods) is capped at 10 requests per minute (30 with the High Volume add-on).

Implement exponential backoff in your loading scripts. Monitor the Retry-After header in Zendesk's 429 responses. Track exactly which records returned errors — pushing too hard without tracking failures creates data integrity gaps that are expensive to reconstruct.

Common Failure Modes and Edge Cases

Suspended users cause import failures. If a Zendesk user referenced as requester_id or author_id is suspended, the ticket import fails silently or returns an error. Reactivate suspended users before import, or map them to a placeholder account.

Salesforce PersonAccount orgs. If the source org uses PersonAccounts (combined Account + Contact), the extraction logic changes — PersonAccounts don't have a separate Contact record. The SOQL query for standard Contacts won't return PersonAccounts. Query Account WHERE IsPersonAccount = true separately and handle them as end-users with no associated Organization, or create a placeholder Organization.

Rich text and HTML formatting. Salesforce EmailMessage stores HTML in HtmlBody. Use Zendesk's html_body field in the comment payload to preserve formatting. If you use value or body instead, HTML tags render as literal text in the Zendesk UI.

CaseComment has no HTML field. The Salesforce CaseComment object only has CommentBody (plain text). Unlike EmailMessage, there is no HtmlBody. What you extract is what you get — don't attempt to pass it through as html_body unless you're wrapping it in paragraph tags.

Timezone handling. Salesforce stores all timestamps in UTC. Zendesk's import API also expects UTC (ISO 8601). Don't convert — pass through directly.

ID collisions on re-runs. If you need to re-run the import after fixing a bug, Zendesk creates duplicate tickets unless you check external_id first. Use the GET /api/v2/tickets?external_id={id} endpoint before each import call if re-running partially completed batches.

The 750,000-ticket performance threshold. Zendesk's documentation recommends using archive_immediately=true for imports exceeding 750,000 tickets to avoid impacting active ticket performance. Below this threshold, archive_immediately is optional but still safe to use for historical closed tickets.

Soft-deleted Salesforce records. Records in the Salesforce Recycle Bin are excluded from standard SOQL queries. If the count difference between a standard query and ALL ROWS is significant, decide whether to restore and migrate those records, or explicitly document them as excluded from the migration scope.

Migration Methods Compared

Method Best For Timeline Risk Level
DIY scripting (Python/Node + both APIs) Engineering teams with API experience and <100k tickets 3–6 weeks Medium — high effort, full control
Automated migration tools (Help Desk Migration, etc.) Standard migrations with straightforward field mapping 1–2 weeks Low-Medium — limited customization
Managed migration service Complex orgs, large volumes, custom objects, zero-downtime requirement 1–3 weeks Low — handled by specialists

DIY scripting gives you full control but requires significant engineering investment. Automated tools handle the common path well but struggle with custom objects, PersonAccounts, complex attachment chains, and edge cases like empty comment bodies. They also cannot handle the Connected App setup, pre-migration auditing, or post-migration workflow reconstruction. Managed services handle the complexity upfront.

Running a Parallel-Run Period

Most teams run both platforms in parallel for 1–2 weeks after the initial migration. During this window:

  • New tickets go into Zendesk (production)
  • Agents reference old tickets in Salesforce (read-only)
  • A delta migration catches any Cases created or updated in Salesforce between the initial export and cutover

The delta extraction uses LastModifiedDate filtering in SOQL:

SELECT Id, CaseNumber, Status, Subject, LastModifiedDate
FROM Case
WHERE LastModifiedDate > 2024-03-15T00:00:00Z  -- your initial export cutover timestamp
ORDER BY LastModifiedDate ASC

On the Zendesk side, check external_id before deciding whether to create or update:

def upsert_ticket(sf_case, transformed_ticket, zendesk_client):
    """
    Check if ticket exists by external_id. Update if found, create if not.
    Uses external_id set during initial migration (e.g., 'sf_case_0019283').
    """
    external_id = f"sf_case_{sf_case['Id']}"
    existing = get_ticket_by_external_id(external_id, zendesk_client)
    
    if existing:
        # Update mutable fields — note: closed tickets cannot be updated via standard API
        # Use the Import API endpoint even for updates if the ticket is closed
        ticket_id = existing['id']
        if existing['status'] == 'closed':
            print(f"WARNING: Ticket {ticket_id} is closed — skipping update (immutable via standard API)")
            return existing
        else:
            return zendesk_client.tickets.update(ticket_id, transformed_ticket)
    else:
        return zendesk_client.imports.tickets.create(transformed_ticket)

For detailed guidance on running a zero-downtime migration, see our Zero-Downtime Help Desk Data Migration guide.

Workflow Rebuild: What Can't Be Migrated

Salesforce automations do not transfer to Zendesk. Expect to rebuild:

  • Assignment Rules / OmniChannel routing → Zendesk Triggers + Group-based routing
  • Flows (auto-response, escalation, field updates) → Zendesk Triggers and Automations
  • Entitlement Processes / Milestones → Zendesk SLA Policies
  • Approval Processes → No native Zendesk equivalent (use Side Conversations or third-party apps)
  • Email Templates → Zendesk Dynamic Content + Trigger-based emails
  • Quick Actions → Zendesk Macros

Budget 2–4 weeks for workflow reconstruction depending on the number of active Flows and assignment rules in the source org. Inventory every active Flow and assignment rule in Salesforce before cutover — it's faster to find and document them in Salesforce than to reverse-engineer missing behavior in Zendesk after agents are already live.

When This Migration Is the Wrong Move

Be honest about whether Zendesk is the right target:

  • You rely on Account-based support models with hierarchical Accounts, Entitlements per contract, and milestone-driven SLA enforcement. Zendesk's SLA engine is condition-based, not contract-linked.
  • Support agents need real-time access to Opportunities, Quotes, or custom CRM objects. You'll need to build an integration back to Salesforce, which partially defeats the purpose of moving platforms.
  • You have heavy custom object dependencies. If Cases reference 5+ custom objects in Salesforce, migrating that logic to Zendesk Sunshine Custom Objects or losing it entirely may not be acceptable. Sunshine Custom Objects require upfront schema design and have their own API rate limits and relationship constraints.
  • Compliance or audit requirements demand the immutable EmailMessage audit trail that Salesforce provides natively. Zendesk's audit log covers admin actions but not ticket content history with the same granularity.

Making It Stick

A Salesforce Service Cloud to Zendesk migration is a data model translation project, not a file transfer. The technical work — extracting the full Case → EmailMessage → Attachment chain from Salesforce, merging disparate objects into Zendesk's flatter model, importing via the Ticket Import API with correct timestamps and authorship, and rebuilding every automation — requires a structured transformation layer, not just extraction scripts and an import call.

The most common failure points are: skipping the Sandbox test run, forgetting to disable triggers, missing empty comment body handling, not pre-creating dropdown field values, and not setting external_id on tickets before discovering a bug mid-import. Address each of these before you start loading data.

If you scope it well, validate rigorously, and rebuild workflows before cutover — not after — the migration is entirely manageable. It's the teams that skip scoping and start importing before the Zendesk instance is properly configured that burn weeks on rework.

Frequently Asked Questions

Can you migrate Salesforce Service Cloud data directly to Zendesk?
No. There is no native migration path between Salesforce Service Cloud and Zendesk. You must extract data via Salesforce Bulk API 2.0, transform it to match Zendesk's data model (Cases→Tickets, EmailMessage→Comments, Contacts→Users), and import via Zendesk's Ticket Import API.
Why use the Zendesk Import API instead of the standard Tickets API?
The standard Tickets API overwrites historical timestamps with the current date, attributes all comments to the API user, and triggers email notifications to customers. The Ticket Import API suppresses business rules and lets you explicitly set created_at, updated_at, solved_at, and author_id for every ticket and comment.
How long does a Salesforce Service Cloud to Zendesk migration take?
Timeline depends on data volume and complexity. DIY scripted migrations typically take 3–6 weeks. Automated tools handle simpler migrations in 1–2 weeks. Managed migration services usually deliver in 1–3 weeks. The main time sinks are data transformation, attachment uploads, and workflow rebuilding.
How do you handle Salesforce files larger than Zendesk's attachment limit?
Zendesk limits attachments to 50MB per file, while Salesforce allows up to 2GB. Files exceeding 50MB must be uploaded to external storage (like AWS S3), with a direct download link injected into the Zendesk ticket comment.
Will SLAs and metrics work on imported tickets in Zendesk?
No. Zendesk does not calculate metrics or SLAs for imported tickets. Running SLA reports on imported data produces inaccurate results. Tag imported tickets (e.g., 'salesforce-migrated') and exclude them from SLA and metric reports.

More from our Blog