---
title: "Kayako to Freshdesk Migration: Engineering Guide (2026)"
slug: kayako-to-freshdesk-migration-engineering-guide-2026
date: 2026-07-20
author: Roopi
categories: [Migration Guide, Freshdesk]
excerpt: "Step-by-step engineering guide to migrate from Kayako to Freshdesk. Covers API extraction, field mapping, timestamps, rate limits, and zero-downtime cutover."
tldr: "Migrating Kayako to Freshdesk requires archived=1 to extract closed tickets, special handling for timestamp preservation, rate-limit pacing, and a delta-sync architecture for zero-downtime cutover."
canonical: https://clonepartner.com/blog/kayako-to-freshdesk-migration-engineering-guide-2026/
---

# Kayako to Freshdesk Migration: Engineering Guide (2026)


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](https://developer.kayako.com/api/v1/cases/cases/?utm_source=openai))

## 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](https://help.kayako.com/article/48375-retrieving-all-conversations-or-cases?utm_source=openai))
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](https://community.freshworks.com/archives-freshdesk-11421/can-t-create-tickets-in-the-past-via-api-created-at-field-not-available-15465?utm_source=openai))
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](https://help.kayako.com/article/48375-retrieving-all-conversations-or-cases?utm_source=openai))

> [!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](https://developer.kayako.com/api/v1/cases/cases/?utm_source=openai))

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

```python
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](https://developers.freshdesk.com/api/))

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

```python
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](https://developers.freshdesk.com/api/))

## 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](https://community.freshworks.com/archives-freshdesk-11421/can-t-create-tickets-in-the-past-via-api-created-at-field-not-available-15465?utm_source=openai))

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.

> [!NOTE]
> 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](https://developers.freshdesk.com/api/))

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

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

> [!CAUTION]
> **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](https://clonepartner.com/blog/blog/help-desk-data-migration-failed-the-engineers-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](https://clonepartner.com/blog/blog/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.

```python
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](https://developers.freshdesk.com/api/))

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.

```python
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.

```python
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']}")
```

> [!NOTE]
> 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](https://developers.freshdesk.com/api/))

### 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](https://clonepartner.com/blog/blog/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](https://cal.com/clonepartner/meet?duration=30).

> Get a free 30-minute migration assessment. We will review your Kayako instance, map your data, and give you a fixed-price quote with a guaranteed timeline.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

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