Skip to content

Kayako to Freshdesk Migration: Engineering Guide (2026)

Step-by-step engineering guide to migrate from Kayako to Freshdesk. Covers API extraction, field mapping, timestamps, rate limits, and zero-downtime cutover.

Roopi Roopi · · 21 min read
Kayako to Freshdesk Migration: Engineering Guide (2026)
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

A Kayako to Freshdesk migration is an API-to-API orchestration project with three hard problems: extracting archived tickets from Kayako's aging REST API, preserving historical timestamps on the Freshdesk side, and cutting over without freezing your live helpdesk.

This guide covers the full pipeline—authenticated data extraction, field mapping, attachment transfer, rate-limit pacing, and delta-sync cutover—with Python code you can adapt for your own migration.

Teams usually make this move because Kayako's product trajectory has slowed, its integration ecosystem has thinned, and Freshdesk offers stronger automation at competitive price points. The migration itself is where silent data loss happens. Kayako defaults to 10 records per page and omits archived cases unless you explicitly ask for them. Freshdesk's standard ticket creation endpoint stamps the current date instead of preserving the original. And Freshdesk's search API caps at 300 results. A clean migration needs scripting, a mapping sheet, and a cutover plan. (developer.kayako.com)

Why Migrating from Kayako to Freshdesk Is Tricky

Three specific engineering constraints make this migration harder than most helpdesk-to-helpdesk moves:

  1. Kayako's archived ticket behavior. Cases closed for 30+ days are moved to an archive. Default API calls to /api/v1/cases do not include them. If you skip the archived=1 parameter, you silently lose your entire closed ticket history. (help.kayako.com)
  2. Timestamp preservation on the Freshdesk side. The standard Freshdesk POST /api/v2/tickets endpoint stamps the current server time as created_at. It does not accept a historical timestamp through the public API. Freshworks staff have confirmed this in community responses. (community.freshworks.com)
  3. Notification side effects. Creating replies via the standard Freshdesk API sends email notifications to customers. During a bulk import of historical tickets, that means thousands of unwanted emails unless you take explicit steps to prevent it.

If you ignore these constraints, you end up with incomplete data, broken historical reporting, and customers receiving reply notifications for tickets they resolved years ago.

Why Freshdesk's Built-In CSV Import Is Insufficient

Freshdesk offers a CSV import tool in the admin UI under Admin → Support Operations → Import. It handles basic ticket fields but has critical limitations for a Kayako migration:

  • No conversation thread import. CSV import creates tickets with a description but cannot import reply chains, private notes, or conversation histories. Every multi-reply ticket arrives as a single flat entry.
  • No attachment support. Attachments must be uploaded separately via the API.
  • No relationship linking. You cannot map tickets to existing contacts or companies by ID—only by email match, and mismatches silently create duplicate contacts.
  • No custom field type enforcement preview. Dropdown values that don't exist in Freshdesk are silently dropped rather than flagged.
  • Limited to 5,000 records per import. For accounts with tens of thousands of tickets, this means dozens of manual import batches.
  • No timestamp control. The same created_at limitation applies—every imported ticket gets the current date.

For anything beyond a trivial migration (under 500 tickets, no conversation history needed), the API-based approach described in this guide is the only viable path.

Kayako Data Extraction

Authentication

Kayako exposes two API generations. Know which one you are on before writing any code.

Kayako Classic (v3/v4) uses an API key + secret key pair with HMAC-SHA256 signature authentication. The base URL pattern is https://{your-domain}/api/index.php?e=/. Key endpoints differ from Cloud: tickets live at /Tickets/Ticket instead of /cases, and staff records at /Base/Staff. Authentication requires generating an HMAC-SHA256 signature from the API secret key and a salt value, passed as query parameters (apikey, salt, signature).

Kayako Cloud (v5+) uses a REST API at https://{subdomain}.kayako.com/api/v1 with HTTP Basic Auth, where you pass a Base64-encoded email:password credential pair in the Authorization header.

The extraction flow below matches Kayako Cloud. If you are on Classic, keep the same extraction order but swap the auth layer and endpoint paths. (help.kayako.com)

Warning

The Kayako REST API has no concept of staff, team, or department permissions. Once you authenticate, your credentials grant unrestricted read/write access to all helpdesk data. Treat these credentials with the same care as a database superuser account.

Key Endpoints

The minimum dataset for a full migration:

Resource Endpoint Notes
Cases (tickets) GET /api/v1/cases Add archived=1 for closed tickets
Case messages/posts GET /api/v1/cases/:id/posts Includes replies and notes. Supports MESSAGES and NOTES filters.
Users GET /api/v1/users Both customers and agents
Organizations GET /api/v1/organizations Company records
Custom fields GET /api/v1/cases/fields Field definitions and types
Attachments Included in message/post responses Download via content_url

(developer.kayako.com)

Pagination and the archived=1 Parameter

Kayako uses offset-based pagination. The default page size is 10. You can increase this to 100 using the limit parameter. The response includes total_count, offset, and limit fields so you can calculate remaining pages.

Without archived=1, cases closed more than 30 days ago are silently excluded from results. This is the single most common cause of data loss in Kayako migrations.

One detail that trips people up: posts are returned ordered by id descending. Reverse them before import if you want correct chronological thread order in Freshdesk.

A note on updated_since filtering: Kayako Cloud's /api/v1/cases endpoint does not support a server-side updated_since query parameter. This means delta sync requires fetching all cases and filtering client-side by updated_at, which is an O(n) scan on every cycle. For accounts with more than 50,000 cases, consider caching the full case list locally and comparing updated_at values against your manifest rather than re-fetching the entire dataset on each pass. If Kayako supports webhooks in your instance (available on some Cloud plans), a push-based approach using webhook events for case updates can eliminate polling entirely—subscribe to case update events and queue changes for processing.

import time
import requests
from requests.auth import HTTPBasicAuth
 
KAYAKO_BASE = "https://yourcompany.kayako.com/api/v1"
KAYAKO_EMAIL = "admin@yourcompany.com"
KAYAKO_PASSWORD = "your_api_password"
 
def fetch_all_cases(include_archived=True):
    """Fetch all Kayako cases with pagination and retry logic."""
    all_cases = []
    offset = 0
    limit = 100
 
    while True:
        params = {"limit": limit, "offset": offset}
        if include_archived:
            params["archived"] = 1
 
        for attempt in range(5):
            try:
                resp = requests.get(
                    f"{KAYAKO_BASE}/cases",
                    params=params,
                    auth=HTTPBasicAuth(KAYAKO_EMAIL, KAYAKO_PASSWORD),
                    timeout=30
                )
                if resp.status_code == 429:
                    retry_after = int(resp.headers.get("Retry-After", 60))
                    time.sleep(retry_after)
                    continue
                resp.raise_for_status()
                break
            except requests.exceptions.RequestException as e:
                wait = 2 ** attempt
                time.sleep(wait)
        else:
            raise Exception(f"Failed after 5 retries at offset {offset}")
 
        data = resp.json()
        cases = data.get("data", [])
        total_count = data.get("total_count", 0)
 
        all_cases.extend(cases)
        offset += limit
 
        if offset >= total_count:
            break
 
    return all_cases
 
 
def get_case_posts(case_id):
    """Fetch posts for a case, reversed into chronological order."""
    resp = requests.get(
        f"{KAYAKO_BASE}/cases/{case_id}/posts",
        params={"filters": "ALL"},
        auth=HTTPBasicAuth(KAYAKO_EMAIL, KAYAKO_PASSWORD),
        timeout=30
    )
    resp.raise_for_status()
    posts = resp.json().get("data", [])
    return list(reversed(posts))
Tip

Kayako's total_count can change between pages if tickets are created or closed during extraction. Run your initial extraction during a low-traffic window and treat the result as a point-in-time snapshot.

Build a local manifest as you extract. At minimum, store kayako_case_id, requester email, organization ID, updated_at, attachment count, and a content hash. That manifest becomes your source of truth during delta sync and validation—do not rely on Freshdesk search for lookups.

Freshdesk Sandbox Testing

Before running any import against your production Freshdesk instance, set up a sandbox environment. Freshdesk Pro and Enterprise plans include a sandbox accessible under Admin → Account → Sandbox. Growth plans do not include sandbox access—use a separate trial account instead (trial accounts have a 14-day window and a 50 requests/minute rate limit).

Use the sandbox to:

  1. Validate your field mapping by importing 100–200 representative tickets spanning different statuses, priorities, and custom field combinations.
  2. Confirm custom field configurations. Create all custom fields in the sandbox first. Run your import script and check that dropdown values, date formats, and checkbox fields populate correctly.
  3. Test notification behavior. Verify that your import approach (notes vs. replies) does not trigger outbound emails.
  4. Measure throughput. Time how long 1,000 tickets take to import at your plan's rate limit to estimate total migration duration.
  5. Practice rollback. Test your cleanup script (see Rollback Strategy section) to confirm you can delete test data cleanly.

The sandbox is not a perfect replica—it may have different rate limits and does not include marketplace app installations. But it catches the majority of field mapping and API integration issues before they affect production data.

Field-by-Field Mapping to Freshdesk

You cannot push raw Kayako JSON objects into Freshdesk. The data models are structurally similar but differ in naming, status values, and how conversation threads are stored. Pre-create your Freshdesk ticket fields, companies, groups, and agents before the first import. (developers.freshdesk.com)

Tickets

Kayako Field Freshdesk Field Notes
subject subject Direct 1:1. If blank, synthesize from the first public post.
First message content description HTML content. Check for inline images.
status status Map to Freshdesk integers: 2=Open, 3=Pending, 4=Resolved, 5=Closed
priority priority Map to Freshdesk integers: 1=Low, 2=Medium, 3=High, 4=Urgent
type type Must pre-create matching type values in Freshdesk first
tags tags Array of strings. Normalize casing and whitespace first.
assigned_agent responder_id Map by email address. Agent must exist in Freshdesk.
assigned_team group_id Map team names to Freshdesk group IDs
requester / user_id requester_id or email Match by email. Create contact if missing.
created_at created_at Requires special import handling (see timestamp section)

Conversation Threads

Kayako stores messages (posts) on a case. Each message has a channel type and a creator reference.

Kayako Message Type Freshdesk Equivalent API Endpoint
Customer reply (channel=MAIL, creator=requester) Reply POST /api/v2/tickets/:id/reply
Agent reply (channel=MAIL, creator=agent) Reply POST /api/v2/tickets/:id/reply
Internal note (channel=NOTE) Private note POST /api/v2/tickets/:id/notes with private: true
Public note Public note POST /api/v2/tickets/:id/notes with private: false

The mapping rule: put the first customer-visible message in the ticket description, put later public thread entries in chronological conversation order, and put internal Kayako notes into Freshdesk private notes. For fields that do not have a direct equivalent, concatenate them into a migration note or encode them as tags—do not invent a mapping that distorts the data.

Users and Organizations

Kayako Object Freshdesk Object Key Fields
User (role=customer) Contact name, email, phone, organization
User (role=agent) Agent Must be created in Freshdesk admin first
Organization Company name, domains, custom fields

Create companies first, then contacts, then tickets. This ensures relationship references resolve correctly during import.

Custom Fields

Both platforms support text, dropdown, checkbox, and date custom fields. Freshdesk custom fields use internal API names prefixed with cf_ that differ from their UI labels. Use GET /api/v2/ticket_fields to discover the correct API names.

Freshdesk strictly enforces custom field data types. If a Kayako dropdown value does not exist in the Freshdesk field configuration, the API request will fail with a 400 Bad Request and a validation error specifying the invalid field.

Fields without a direct equivalent: Kayako's conversation-level custom fields (attached to individual messages rather than the ticket) have no Freshdesk counterpart. Concatenate these into a private note. Kayako's SLA plan metadata and macros are configuration objects—they must be rebuilt manually in Freshdesk.

SLA and Automation Rebuild Checklist

Kayako SLA policies, macros, and automation rules do not transfer via API. Rebuild them manually using this mapping framework:

Kayako Object Freshdesk Equivalent Rebuild Steps
SLA Plans SLA Policies (Admin → Helpdesk → SLA Policies) Map response/resolution targets by priority. Freshdesk SLAs are group-scoped; Kayako's may be department-scoped—adjust grouping accordingly.
Macros (canned responses) Canned Responses (Admin → Helpdesk → Canned Responses) Export Kayako macro text, recreate in Freshdesk. Placeholder variables differ: Kayako uses {{ticket.requester}}, Freshdesk uses {{ticket.requester_name}}.
Triggers / Automation rules Automations (Admin → Helpdesk → Automations) Freshdesk splits automations into three types: Ticket Creation, Time Trigger, and Ticket Update. Map each Kayako trigger to the appropriate type.
Business Hours Business Hours (Admin → Account → Business Hours) Recreate timezone and holiday schedules before creating SLA policies, since SLAs reference business hours.

For complex legacy fields you do not want to recreate, concatenate the key-value pairs into an HTML string and append it as a private note on the migrated ticket.

Attachment Transfer

Kayako returns attachment metadata within post/message responses. Each attachment includes a content_url you can use to download the file. You cannot pass a Kayako attachment URL directly to Freshdesk because the Kayako URL requires authentication.

The transfer pattern:

  1. Download each attachment from Kayako to temporary storage.
  2. Upload it to the corresponding Freshdesk ticket as a multipart form-data request in the same reply or note call that creates the conversation entry.
  3. Delete the temp file after confirmed upload.
  4. If cumulative size exceeds the limit, place oversize files in controlled object storage (e.g., S3 with presigned URLs) and insert download links in a private note.
import os
import requests
 
def transfer_attachment(kayako_attachment, freshdesk_ticket_id, fd_domain, fd_api_key):
    """Download attachment from Kayako and upload to Freshdesk ticket."""
    dl_resp = requests.get(
        kayako_attachment["content_url"],
        auth=HTTPBasicAuth(KAYAKO_EMAIL, KAYAKO_PASSWORD),
        stream=True
    )
    dl_resp.raise_for_status()
 
    filename = kayako_attachment.get("name", "attachment")
    file_size = int(dl_resp.headers.get("Content-Length", 0))
 
    # Freshdesk enforces a 20 MB attachment limit per conversation
    if file_size > 20 * 1024 * 1024:
        print(f"Skipping {filename} ({file_size} bytes) - exceeds 20MB limit")
        return None
 
    temp_path = f"/tmp/{filename}"
    with open(temp_path, "wb") as f:
        for chunk in dl_resp.iter_content(chunk_size=8192):
            f.write(chunk)
 
    try:
        with open(temp_path, "rb") as f:
            files = {"attachments[]": (filename, f)}
            data = {
                "body": f"Migrated attachment: {filename}",
                "private": "true"
            }
            upload_resp = requests.post(
                f"https://{fd_domain}.freshdesk.com/api/v2/tickets/{freshdesk_ticket_id}/notes",
                auth=(fd_api_key, "X"),
                files=files,
                data=data
            )
            upload_resp.raise_for_status()
        return upload_resp.json()
    finally:
        os.remove(temp_path)
Warning

Inline images in HTML ticket bodies are a common edge case. Kayako may store inline images as cid: references or src attributes pointing to authenticated Kayako endpoints. During migration, download these images, upload them to Freshdesk, and rewrite the references in the HTML body before creating the conversation entry. Skipping this step results in broken image placeholders in the agent UI.

Freshdesk enforces a 20 MB attachment size limit per conversation on paid plans (Growth and above). Trial and free accounts are limited to 15 MB. Attachments exceeding this limit are silently dropped—your script must catch oversized files and log them before the upload attempt. (developers.freshdesk.com)

Preserving Metadata and Historical Timestamps

This is where most DIY Kayako-to-Freshdesk migrations break down.

The standard Freshdesk ticket creation endpoint (POST /api/v2/tickets) does not accept a historical created_at value. It stamps the current server time on every new ticket. The standard reply endpoint (POST /api/v2/tickets/:id/reply) also uses the current timestamp and sends email notifications to the requester. Freshworks staff have confirmed in community responses that the public API does not support backdating. (community.freshworks.com)

If you use the standard API for a bulk import, every migrated ticket shows today's date and every historical reply triggers a customer email.

Practical Approaches to Timestamp Preservation

  1. Contact Freshdesk support before your migration. Request import API access or whitelisting for timestamp preservation. Freshdesk has internal tooling for migrations and may grant special access for bulk imports that allows setting historical created_at and updated_at values. File the request through Admin → Support → Contact Us and reference "data migration" and "timestamp preservation" explicitly. Response times vary; allow 5–10 business days.
  2. Use private notes instead of replies for historical conversation entries. Notes created with private: true do not send customer notifications. Include the original timestamp and author in the note body using a structured format:
    [Migrated] Original reply by agent@company.com on 2024-03-15T14:22:00Z
    
  3. Store original timestamps in custom fields. Create a custom date field called original_created_at on Freshdesk tickets and populate it during import. This preserves the data for reporting even though the system created_at reflects the import date.
  4. Use a managed migration service that has established import pathways with Freshdesk, including timestamp preservation and notification suppression.
Info

The standard POST /api/v2/tickets/:id/reply endpoint sends email notifications to customers and this cannot be suppressed through API parameters. Using POST /api/v2/tickets/:id/notes with private: false creates a visible note without sending email. For historical conversations, this is typically the safer import path.

Be honest about this trade-off before the project starts. The public API fallback is readable, but it is not historically exact. If original created_at values, first-response metrics, or true thread authorship are mandatory, route the migration through Freshdesk support or a migration partner with established import access.

Freshdesk API Rate Limits and Pacing

Freshdesk enforces per-minute rate limits at the account level, not per API key. Every script, installed app, and integration draws from the same bucket. During a migration, your import script competes with any live integrations already running. (developers.freshdesk.com)

Rate Limits by Plan

Plan Requests per Minute Trial
Growth 100/min 50/min
Pro 400/min 50/min
Enterprise 700/min 50/min

Freshdesk also enforces per-endpoint sub-limits (ticket creates and ticket lists have lower ceilings than the overall limit). Check your actual response headers rather than relying solely on published numbers—Freshdesk documentation has historically shown legacy plan names and older rate structures.

Freshdesk returns rate limit status on every response via X-RateLimit-Total, X-RateLimit-Remaining, and X-RateLimit-Used-CurrentRequest. When the limit is exceeded, the API returns HTTP 429 with a Retry-After header.

Common Freshdesk API Errors During Migration

HTTP Status Error Common Cause Fix
400 "Validation failed" Custom field dropdown value doesn't exist in Freshdesk config Pre-create all dropdown choices before import
400 "It should be a valid email address" Requester email is malformed or empty in source data Validate emails before sending; assign a fallback requester
400 "Agent not found" responder_id references a deactivated or non-existent agent Map agents by email with a pre-built lookup table
401 "Authentication failure" Expired or invalid API key Regenerate key in Freshdesk admin. Note: this still counts against your rate limit.
404 "Ticket not found" Race condition—ticket was merged or deleted between creation and note attachment Retry with lookup; log for manual review
409 "Conflict" Duplicate contact creation (same email) Use GET /api/v2/contacts?email={email} before creating
422 "Unprocessable Entity" Required field missing (e.g., subject, description, or email) Validate payload completeness before sending
429 "Rate limit exceeded" Too many requests in the current minute Respect Retry-After header; reduce concurrency
502/503 "Bad Gateway" / "Service Unavailable" Freshdesk infrastructure issue Exponential backoff with jitter; retry up to 5 times
import time
import requests
 
class FreshdeskClient:
    def __init__(self, domain, api_key, max_retries=5):
        self.base_url = f"https://{domain}.freshdesk.com/api/v2"
        self.auth = (api_key, "X")
        self.max_retries = max_retries
 
    def request(self, method, endpoint, **kwargs):
        url = f"{self.base_url}/{endpoint}"
        for attempt in range(self.max_retries):
            resp = requests.request(method, url, auth=self.auth, timeout=60, **kwargs)
 
            if resp.status_code == 429:
                retry_after = int(resp.headers.get("Retry-After", 60))
                print(f"Rate limited. Waiting {retry_after}s (attempt {attempt + 1})")
                time.sleep(retry_after)
                continue
 
            if resp.status_code in (500, 502, 503, 504):
                time.sleep(2 ** attempt)
                continue
 
            # Proactive pacing: slow down when remaining calls are low
            remaining = int(resp.headers.get("X-RateLimit-Remaining", 100))
            if remaining < 10:
                time.sleep(5)
 
            resp.raise_for_status()
            return resp
 
        raise Exception(f"Failed after {self.max_retries} retries: {url}")
 
    def create_ticket(self, data):
        return self.request("POST", "tickets", json=data)
 
    def create_note(self, ticket_id, data, files=None):
        if files:
            return self.request("POST", f"tickets/{ticket_id}/notes", data=data, files=files)
        return self.request("POST", f"tickets/{ticket_id}/notes", json=data)
 
    def create_contact(self, data):
        return self.request("POST", "contacts", json=data)
Danger

Even failed requests count against your rate limit. A 400 from a malformed payload or a 401 from a bad key still consumes a call. Validate your payloads before sending them. On a Growth plan, a tight loop of bad requests can burn through 100 calls in under a minute and lock you out.

A practical pacing rule: stay at 60–70% of your plan's ceiling during bulk loads, leaving room for retries, validation calls, and background traffic from apps already installed in the helpdesk. If your DIY script ignores these limits, read Help Desk Data Migration Failed? The Engineer's Rescue Guide before you start.

Migration Throughput Estimates

Actual throughput depends on ticket complexity (number of conversations and attachments per ticket), but these benchmarks give a realistic planning baseline assuming an average of 4 conversations and 1.5 attachments per ticket:

Plan Effective Rate (70% ceiling) API Calls per Ticket (avg) Tickets per Hour 10K Tickets 50K Tickets
Growth (100/min) 70/min ~6 ~700 ~14 hours ~71 hours
Pro (400/min) 280/min ~6 ~2,800 ~3.5 hours ~18 hours
Enterprise (700/min) 490/min ~6 ~4,900 ~2 hours ~10 hours

Each ticket requires approximately 6 API calls: 1 for ticket creation, 3–4 for conversation entries (replies and notes), and 1–2 for attachment uploads. These estimates assume sequential processing; parallelization can improve throughput but increases rate-limit collision risk. On a Growth plan, a 50,000-ticket migration will take approximately 3 full days of continuous runtime—plan your cutover schedule accordingly.

Delta-Sync Architecture for Zero-Downtime Cutover

Freezing your helpdesk during migration is not acceptable for a live support team. A zero-downtime migration means agents keep working in Kayako while the migration runs—no ticket freeze, no maintenance window. The architecture uses a bulk-then-delta pattern. For a detailed breakdown, see Zero-Downtime Help Desk Data Migration.

Cutover Workflow

  1. Initial bulk sync. Extract all Kayako cases (including archived), users, organizations, posts, and attachments. Map, transform, and load them into Freshdesk. This is the longest phase—see throughput estimates above for planning.
  2. Delta sync loop. After the bulk sync completes, run incremental pulls from Kayako using updated_at filters to capture only tickets created or modified since the bulk extraction started. Push these deltas to Freshdesk. Run this every 2–4 hours, always including archived=1 so closed history does not disappear from late passes.
  3. Final delta sync. Run one last delta pull. The time window should be minutes, not hours. On a 50K-ticket account, the final delta typically covers 50–200 tickets if timed correctly.
  4. DNS/routing switch. Redirect your support email forwarding and widget endpoints from Kayako to Freshdesk.
  5. Verification. Run ticket count comparisons, spot-check content, and validate custom field mapping.
def fetch_updated_cases(since_timestamp):
    """
    Fetch cases updated after a given ISO timestamp.
 
    Note: Kayako Cloud does not support server-side updated_since filtering.
    This function fetches all cases and filters client-side, which is O(n)
    on every call. For accounts with >50K cases, consider maintaining a local
    manifest and comparing updated_at values against it instead.
    """
    all_updated = []
    offset = 0
    limit = 100
 
    while True:
        params = {"limit": limit, "offset": offset, "archived": 1}
        resp = requests.get(
            f"{KAYAKO_BASE}/cases",
            params=params,
            auth=HTTPBasicAuth(KAYAKO_EMAIL, KAYAKO_PASSWORD),
            timeout=30
        )
        resp.raise_for_status()
        data = resp.json()
        cases = data.get("data", [])
 
        # Client-side filter for updated_at
        for case in cases:
            if case["updated_at"] > since_timestamp:
                all_updated.append(case)
 
        if offset + limit >= data.get("total_count", 0):
            break
        offset += limit
 
    return all_updated
Tip

Use an overlap window on every delta cycle. If your watermark is 2026-07-20T10:00:00Z, re-fetch from 09:50:00Z and deduplicate by case ID. That small overlap absorbs clock drift, delayed indexing, and race conditions around tickets being closed or reopened at the boundary.

Keep a local manifest table keyed by source case ID with the destination Freshdesk ticket ID and a payload hash. Use this for upsert logic instead of relying on Freshdesk search—it is faster, not subject to the 300-result cap, and avoids indexing delays.

Bypassing the Freshdesk Search Pagination Wall

Freshdesk's search API is limited to 10 pages of 30 results—a hard cap of 300 tickets per query. Archived tickets are excluded from search results entirely, and index updates can lag by several minutes. (developers.freshdesk.com)

When you do need to search (for deduplication checks during delta sync, for example), use time-based chunking: query small date ranges and slide the window forward. Search for tickets updated between midnight and 1 AM, process those results, then move to the next hour. This keeps results under 300 per query.

For count verification on large accounts, use the List All Tickets endpoint with updated_since date ranges instead of the search endpoint.

Rollback Strategy

If the migration fails partway through or validation reveals systemic data issues, you need a cleanup path. Freshdesk does not offer a "bulk undo" feature.

Cleanup Approach

  1. Track everything in your manifest. Every Freshdesk ticket ID, contact ID, and company ID created during import should be logged with its source Kayako ID.
  2. Bulk delete via API. Use DELETE /api/v2/tickets/:id to remove migrated tickets. This moves them to trash (recoverable for 30 days). For permanent deletion, follow with DELETE /api/v2/tickets/:id/restore is not needed—tickets in trash auto-purge. Freshdesk rate limits apply to deletes as well.
  3. Contact cleanup. Use DELETE /api/v2/contacts/:id for contacts created during migration. Contacts with associated tickets cannot be deleted until those tickets are removed first.
  4. Deletion throughput. At 70% rate utilization on a Pro plan, you can delete approximately 280 records per minute. Cleaning up 50,000 tickets takes roughly 3 hours.
def rollback_migration(manifest, fd_client):
    """Delete all Freshdesk resources created during migration."""
    # Delete tickets first (contacts can't be deleted while they have tickets)
    for entry in manifest:
        if entry.get("freshdesk_ticket_id"):
            try:
                fd_client.request("DELETE", f"tickets/{entry['freshdesk_ticket_id']}")
            except Exception as e:
                print(f"Failed to delete ticket {entry['freshdesk_ticket_id']}: {e}")
 
    # Then delete contacts created during migration
    for entry in manifest:
        if entry.get("freshdesk_contact_id") and entry.get("created_during_migration"):
            try:
                fd_client.request("DELETE", f"contacts/{entry['freshdesk_contact_id']}")
            except Exception as e:
                print(f"Failed to delete contact {entry['freshdesk_contact_id']}: {e}")
Warning

Do not delete contacts that existed in Freshdesk before the migration. Your manifest should flag which contacts were pre-existing (matched by email) vs. created during import. Deleting a pre-existing contact removes their entire ticket history in Freshdesk.

Data Privacy and GDPR Considerations

Migrating customer PII between platforms has regulatory implications under GDPR, CCPA, and similar frameworks:

  • Data processing agreement (DPA). Ensure you have a DPA with both Kayako and Freshdesk that covers data transfer between processors. Both platforms offer standard DPAs on request.
  • Data residency. Kayako and Freshdesk may store data in different regions. Verify that the target Freshdesk instance's data center location is compliant with your obligations (Freshdesk offers US, EU, India, and Australia data centers).
  • Temporary storage. If your migration script writes attachments or ticket data to disk (/tmp/ in the code examples above), ensure that temporary storage is encrypted and files are deleted after transfer. Do not persist customer data on migration infrastructure longer than necessary.
  • Right to erasure. If you receive a GDPR deletion request during migration, you must honor it in both the source (Kayako) and target (Freshdesk) systems. Track deletion requests separately and apply them post-migration.
  • Audit trail. Your migration manifest serves as a processing record. Retain it for compliance purposes but restrict access to authorized personnel.

Validation and Verification

Do not assume a 200 OK response means the data is correct. A migration can match ticket counts and still fail operationally. Run these checks before switching DNS.

Automated Checks

  • Ticket count comparison. Total cases extracted from Kayako (including archived) vs. total tickets in Freshdesk. The numbers must match.
  • Status distribution. Compare open, pending, resolved, and closed counts between source and target.
  • Timestamp accuracy. For a random sample of 50–100 tickets, compare the original Kayako created_at with the value stored in Freshdesk (either the system field or your custom original_created_at field).
  • Custom field integrity. Query Freshdesk for tickets where custom fields are null or empty, and cross-reference against source data.
  • User and company linkage. Confirm that every migrated ticket has a valid requester_id pointing to an existing contact, and that contacts are linked to the correct companies.
  • Conversation count per ticket. Verify that the number of posts on a Kayako case matches the conversation count on the corresponding Freshdesk ticket.
  • Attachment count. Spot-check attachment-heavy tickets to confirm files transferred correctly and are downloadable.
def verify_migration(kayako_cases, fd_client):
    """Run basic post-migration verification checks."""
    # Count comparison
    fd_ticket_count = 0
    page = 1
    while True:
        resp = fd_client.request("GET", f"tickets?page={page}&per_page=100")
        tickets = resp.json()
        if not tickets:
            break
        fd_ticket_count += len(tickets)
        page += 1
 
    print(f"Kayako cases: {len(kayako_cases)}")
    print(f"Freshdesk tickets: {fd_ticket_count}")
    if len(kayako_cases) != fd_ticket_count:
        print("WARNING: Count mismatch!")
 
    # Spot-check sample
    import random
    sample = random.sample(kayako_cases, min(50, len(kayako_cases)))
    mismatches = []
    for case in sample:
        resp = fd_client.request(
            "GET",
            f"search/tickets?query=\"cf_legacy_kayako_id:'{case['id']}'\""
        )
        results = resp.json().get("results", [])
        if not results:
            mismatches.append({"kayako_id": case["id"], "issue": "not_found"})
            continue
 
        ticket = results[0]
        if case["subject"] != ticket.get("subject"):
            mismatches.append({"kayako_id": case["id"], "issue": "subject_mismatch"})
        if not ticket.get("requester_id"):
            mismatches.append({"kayako_id": case["id"], "issue": "no_requester"})
 
    print(f"Spot check: {len(mismatches)} issues in {len(sample)} samples")
    for m in mismatches:
        print(f"  Case {m['kayako_id']}: {m['issue']}")
Info

When validating thread completeness, use GET /api/v2/tickets/{id}/conversations. Do not rely on include=conversations on the ticket view endpoint—it only returns up to 10 conversations. (developers.freshdesk.com)

Manual Spot Checks

Pull 10–20 tickets across different date ranges and statuses. Open each one in the Freshdesk UI and verify:

  • Subject and description match the Kayako original
  • All conversation entries appear in the correct order
  • Attachments are downloadable and not corrupted
  • Custom field values are populated correctly
  • Agent assignments resolve to the correct Freshdesk agent
  • Inline images render correctly (not broken cid: references)

For a complete QA checklist, pair this guide with the Freshdesk Migration Checklist.

Skip the Engineering Effort

Building a production-quality Kayako-to-Freshdesk migration pipeline takes 80–160 hours of engineering time, depending on ticket volume, custom field complexity, and attachment handling. Based on the throughput estimates above, a 50,000-ticket migration on a Pro plan requires roughly 18 hours of runtime alone—before accounting for sandbox testing, field mapping, delta sync cycles, validation, and debugging inline images, rate-limit edge cases, or timestamp workarounds.

If you want the outcome without tying up your engineering team, book a free migration assessment.

Frequently Asked Questions

How do I export archived tickets from the Kayako API?
By default, the Kayako GET /api/v1/cases endpoint only returns active tickets. Add the archived=1 query parameter to include cases closed more than 30 days ago. Without it, your entire closed ticket history is silently excluded. Example: /api/v1/cases?limit=100&offset=0&archived=1.
Can I set historical created_at timestamps when importing tickets to Freshdesk?
The standard Freshdesk POST /api/v2/tickets endpoint does not accept a historical created_at value—it always stamps the current server time. Freshworks staff have confirmed the public API does not support backdating. To preserve original timestamps, contact Freshdesk support for import API access, use a managed migration service with established import pathways, or store original dates in a custom field as a workaround.
What are Freshdesk API rate limits by plan?
Freshdesk enforces account-level per-minute limits: Growth gets 100 calls/min, Pro gets 400 calls/min, and Enterprise gets 700 calls/min. Trial accounts are limited to 50 calls/min. There are also per-endpoint sub-limits. Always check the X-RateLimit-Remaining response header for your actual allocation.
Does the Freshdesk API send email notifications when creating replies during migration?
Yes. The standard POST /api/v2/tickets/:id/reply endpoint sends email notifications to customers and this cannot be suppressed via API parameters. To avoid mass-emailing customers during a historical import, use POST /api/v2/tickets/:id/notes with private: true for internal notes, or private: false for visible notes that do not trigger email.
How do I bypass the Freshdesk 300-ticket search limit?
Freshdesk search returns 30 results per page with a maximum of 10 pages (300 tickets total). Use time-based chunking: query small date ranges and slide the window forward to keep results under 300 per query. For count verification, use the List All Tickets endpoint with updated_since filters instead of search. For migration lookups, maintain a local manifest table keyed by source ID rather than relying on search.

More from our Blog

Freshdesk Migration Checklist
Checklist/Freshdesk

Freshdesk Migration Checklist

Planning a move to Freshdesk? Our migration checklist covers everything from agent mapping to custom fields, ensuring a clean data import without service disruption

Tejas Mondeeri Tejas Mondeeri · · 5 min read
Zero-Downtime Help Desk Data Migration: How to Keep Support Running During the Move
Help Desk

Zero-Downtime Help Desk Data Migration: How to Keep Support Running During the Move

This guide details the 3-stage technical process for a zero-downtime help desk migration. Learn how to use an initial bulk data transfer, a continuous delta migration (Change Data Capture), and a seamless final cutover to move platforms without any service interruption. Discover how an engineer-led approach can guarantee a 100% accurate, 50x faster migration.

Raaj Raaj · · 6 min read