---
title: "Freshchat to Kayako Migration: A Technical Guide"
slug: freshchat-to-kayako-migration-a-technical-guide
date: 2026-08-31
author: Raajshekhar Rajan
categories: [Freshchat, Kayako, Migration Guide]
excerpt: "Technical guide to migrating from Freshchat to Kayako — covering API extraction, bulk import with timestamp preservation, data model mapping, and zero-downtime cutover."
tldr: Freshchat to Kayako migration requires API-to-API extraction and bulk import. Kayako's bulk cases endpoint preserves historical timestamps. Budget 5–9 days for a mid-size dataset.
canonical: https://clonepartner.com/blog/freshchat-to-kayako-migration-a-technical-guide
---

# Freshchat to Kayako Migration: A Technical Guide


# Freshchat to Kayako Migration: A Technical Guide

> [!NOTE]
> **TL;DR:** No native migration path exists between Freshchat and Kayako. You must extract via the Freshchat REST API (v2), transform conversations into case payloads, and load via Kayako's REST API (v1) — specifically the bulk import endpoint `POST /api/v1/bulk/cases`. Kayako's bulk endpoint accepts historical `created_at` and `updated_at` timestamps on both cases and posts, which is rare among helpdesk platforms. The bottleneck is extraction: Freshchat's Messages API caps at 50 messages per page per conversation, forcing an N+1 query pattern that dominates total runtime. For a 15,000-conversation dataset averaging 8 messages per conversation, sustained extraction at ~100 req/min yields approximately 75–90 hours of API call time alone — budget 5–9 days of total pipeline runtime. **Last verified against Freshchat REST API v2 and Kayako Helpdesk REST API v1 (not Kayako Classic).**

## What This Migration Actually Involves

**Freshchat** is Freshworks' conversation-first messaging platform. It handles real-time conversations across web chat, WhatsApp, Facebook Messenger, Instagram, Apple Business Chat, and email. Its data model is conversation-centric: every interaction is a `Conversation` containing a stream of `Messages`, each with its own sender, timestamp, and message type (`normal`, `private`, `system`). Conversations have no subject lines. Statuses are limited to four values: `new`, `assigned`, `waiting`, and `resolved`.

**Kayako** (this guide applies to Kayako Helpdesk, not Kayako Classic) is a case-based helpdesk, and a [Freshchat to Kayako migration](https://clonepartner.com/blog/migrate/freshchat-to-kayako) has to build those cases from scratch. Interactions are stored as `Cases`, which require a `subject`, `department`, `status`, `priority`, and `type`. Individual replies within a case are stored as `Posts`. Kayako supports channels including `MAIL`, `HELPCENTER`, `TWITTER`, `MESSENGER`, `FACEBOOK`, and `NOTE`. The bulk import endpoint only supports `MAIL` and `NOTE` — other channel types must be stored as metadata.

The structural mismatch between these two models—a common challenge when [translating a conversation-centric platform into a ticket-centric one](https://clonepartner.com/blog/blog/freshchat-to-groove-migration-a-technical-guide)—drives four concrete design decisions:

- **One Freshchat conversation maps to one Kayako case.** The `conversation_id` is the most stable thread key across both the messages API and raw transcript exports.
- **You must synthesize subject lines.** Freshchat has no concept of a ticket subject. The standard approach is to truncate the first text content in `message_parts` to roughly 50 characters.
- **Rich message parts need flattening.** Freshchat messages contain a `message_parts` array that can include text, images, buttons, quick-reply options, and collection cards. Kayako posts accept plain text or HTML. Bot-interaction UI elements (carousels, input controls) lose their interactive structure and must be serialized as readable fallbacks.
- **Actor types are broader in Freshchat.** Freshchat message actors can be `user`, `agent`, `bot`, or `system`. Kayako posts require a `creator_id` referencing a pre-existing Kayako user. Bot and system messages must be mapped to a designated placeholder user account.

## Why Teams Move from Freshchat to Kayako

- **Ticket-centric workflow needs.** Teams that need traditional ticket management with SLA tracking, case types, priorities, and formal resolution workflows find Kayako's model a better fit than Freshchat's conversation-first approach.
- **Self-hosted requirements.** Kayako Classic offers self-hosted deployment. Freshchat is cloud-only.
- **Help center integration.** Kayako includes a built-in help center tightly coupled to the case system. Freshchat relies on Freshdesk or external knowledge bases.
- **Channel consolidation.** Teams already using Kayako for email and ticket support who want to fold Freshchat messaging history into a single system of record.

## Freshchat API: Extraction Constraints

The Freshchat REST API v2 is your primary extraction path. There are two practical extraction patterns, and most successful projects use both.

**Base URL:** `https://{subdomain}.freshchat.com/v2`

> [!WARNING]
> **Regional endpoints:** Freshchat instances in the EU region use `api.eu.freshchat.com` instead of `{subdomain}.freshchat.com`. Confirm your base URL before starting by checking Admin → Settings → API Tokens, which displays the active region.

**Authentication:** Bearer token via API key generated in Freshchat Admin → Settings → API Tokens. Pass as `Authorization: Bearer <API_KEY>` header.

### Key Extraction Endpoints

| Endpoint | Purpose | Pagination | Max per page |
|---|---|---|---|
| `GET /v2/conversations` | List conversations | `page` + `items_per_page` | 100 |
| `GET /v2/conversations/{id}/messages` | Messages per conversation | `page` + `items_per_page` | **50** |
| `GET /v2/agents` | Agent roster | `page` + `items_per_page` | 100 |
| `GET /v2/contacts` | Customer contacts | `page` + `items_per_page` | 100 |
| `POST /v2/reports/raw/` | Raw report export | Date-windowed | N/A |

### Rate Limits

Freshchat rate limits are enforced but not fully documented per-plan. The API returns HTTP `429` when limits are exceeded. The dashboard API has a documented limit of 100 requests per minute. Response headers include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` — build your extractor to read these headers dynamically instead of hard-coding a fixed request cadence. Implement exponential backoff starting at 2 seconds, doubling on each consecutive `429`.

### The N+1 Query Problem

The conversations list endpoint (`GET /v2/conversations`) returns metadata — `channel_id`, `status`, `user_id` — but not the actual message content. For every conversation, you must make at least one separate call to fetch its messages, paginated at 50 per page.

**Concrete arithmetic for a 15,000-conversation dataset:**
- 15,000 conversations ÷ 100 per page = 150 list API calls
- 15,000 conversations × average 8 messages ÷ 50 per page = ~2,400 message-fetch calls
- Total calls: ~2,550 minimum (more for longer conversations)
- At 100 req/min with a conservative 600ms average call duration: 2,550 calls ÷ 100 req/min = ~25.5 minutes of pure call time under ideal conditions
- In practice, `429` backoff events, network latency, and sequential (non-async) execution multiply this 3–6× — yielding 75–150 hours for a naively synchronous implementation

Run message fetches asynchronously using a worker queue or Python's `aiohttp` with a semaphore controlling concurrency. Respect `X-RateLimit-Remaining` on every response. Do not hard-code sleep intervals.

### Raw Reports: A Faster Path for Historical Backfill

Freshchat's raw reports API (`POST /v2/reports/raw/`) emits pre-built CSV datasets designed for reporting and downstream processing. This is faster than per-conversation fetches for large historical ranges, but has strict windowing constraints:

- `start` cannot be earlier than **15 months** from the current date
- `end` cannot be more than one month after `start`
- The **Chat-Transcript** report is limited to **24-hour windows** per request

For transcript reconstruction, the most useful fields are: `conversation_id`, `message_id`, `customer_id`, `message_type`, `detailed_message_type`, `message_parts`, and `created_time`. A multi-day conversation must be stitched together by `conversation_id` across many daily extracts. Companion datasets (`Conversation-Created`, `Conversation-Resolved`, `Conversation-Activity`) are useful for count reconciliation, reopen detection, and QA validation.

> [!TIP]
> **Hybrid extraction wins.** Use Chat-Transcript and companion reports for the bulk historical backfill, then use the per-conversation messages API (`GET /v2/conversations/{id}/messages` with `from_time`) for open-thread deltas during cutover. This minimizes total API call volume without losing control over in-flight conversations.

### Contact/Agent Split

Freshchat maintains separate object types for contacts (customers) and agents. `GET /v2/contacts` does not return agents, and `GET /v2/agents` does not return contacts. Your user-mapping script must pull both endpoints and merge them into a unified `freshchat_id → type → kayako_id` lookup table before transformation begins.

### Handling Multi-Channel Contacts

A single Freshchat contact may have simultaneous conversations across multiple channels — web chat, WhatsApp, and email — resulting in multiple distinct `conversation_id` values for the same requester. These are not duplicates; they are separate threads. Do not deduplicate by `user_id` alone. Each conversation should map to its own Kayako case. If you need to link them, store the shared `user_id` (mapped to the Kayako requester) as a common field — the case-level relationship is established through the shared requester, not through merging threads.

### Handling Attachments

Freshchat attachments are returned as signed URLs within the message payload. These URLs are time-limited and will expire — the exact TTL is not publicly documented but is typically hours to days. Download all attachments during the extraction phase, not during transformation. Store them locally or in object storage (S3, GCS) keyed by `message_id` + filename. Attempting to pass a Freshchat URL directly to Kayako will fail silently for any attachment where the URL has already expired.

### Multilingual and Encoding Considerations

Freshchat deployments in Arabic, Japanese, or CJK character sets can produce encoding issues when flattening `message_parts` to HTML or plain text. Always use `ensure_ascii=False` when serializing JSON message content in Python. For HTML output, set `charset=utf-8` in your content-type headers when uploading to Kayako. Test with a multilingual conversation sample before running the full pipeline.

### Extraction Script Pattern

```python
import requests
import time

FRESHCHAT_BASE = "https://{subdomain}.freshchat.com/v2"
HEADERS = {
    "Authorization": "Bearer <API_KEY>",
    "Accept": "application/json"
}

def fetch_all_conversations():
    conversations = []
    page = 1
    while True:
        resp = requests.get(
            f"{FRESHCHAT_BASE}/conversations",
            headers=HEADERS,
            params={"page": page, "items_per_page": 100,
                    "sort_by": "created_time", "sort_order": "asc"}
        )
        if resp.status_code == 429:
            wait = int(resp.headers.get("Retry-After", 10))
            time.sleep(wait)
            continue
        data = resp.json()
        convos = data.get("conversations", [])
        if not convos:
            break
        conversations.extend(convos)
        if not data.get("pagination", {}).get("next_link"):
            break
        page += 1
    return conversations

def fetch_messages(conversation_id):
    messages = []
    page = 1
    while True:
        resp = requests.get(
            f"{FRESHCHAT_BASE}/conversations/{conversation_id}/messages",
            headers=HEADERS,
            params={"page": page, "items_per_page": 50}
        )
        if resp.status_code == 429:
            wait = int(resp.headers.get("Retry-After", 10))
            time.sleep(wait)
            continue
        data = resp.json()
        msgs = data.get("messages", [])
        if not msgs:
            break
        messages.extend(msgs)
        page += 1
    return messages
```

For a detailed breakdown of all Freshchat export methods, see [How to Export Data from Freshchat: Methods, API Limits & Formats](https://clonepartner.com/blog/blog/how-to-export-data-from-freshchat-methods-api-limits-formats).

## Kayako API: Import Constraints

> [!WARNING]
> **Product version scope:** This section applies to **Kayako Helpdesk** (cloud, current product line) using REST API v1. It does not apply to Kayako Classic (self-hosted, legacy) or Kayako Engage. Endpoint paths, authentication, and bulk import behavior differ across product generations. Verify your Kayako product line before proceeding.

**Base URL:** `https://{subdomain}.kayako.com/api/v1`

**Authentication:** HTTP Basic Auth with admin username and password.

### Key Import Endpoints

| Endpoint | Purpose | Batch size | Timestamp support |
|---|---|---|---|
| `POST /api/v1/bulk/cases` | Bulk case creation | **200 cases max** | ✅ `created_at`, `updated_at` |
| `POST /api/v1/cases` | Single case creation | 1 | ❌ |
| `POST /api/v1/cases/:id/reply` | Add post to case | 1 | ❌ |
| `POST /api/v1/bulk/users` | Bulk user creation | 200 | ✅ |

### Critical Import Details

- **Bulk import preserves timestamps.** `POST /api/v1/bulk/cases` accepts `created_at` and `updated_at` in ISO-8601 format (`YYYY-MM-DDThh:mm:ssTZD`) on both cases and inline posts. The single-case `POST /api/v1/cases` endpoint and the `POST /api/v1/cases/:id/reply` endpoint do not support timestamp overrides — they stamp the current server time. Use the bulk endpoint for all historical data. Always verify timestamp preservation behavior in your specific Kayako sandbox instance before full-scale import.
- **200 cases per batch, async execution.** Each bulk request can contain up to 200 cases with inline `posts`. The endpoint returns `202 Accepted` with a job ID. You must poll `GET /api/v1/jobs/{id}` until the job status returns `COMPLETED` or `FAILED` before submitting the next dependent batch.
- **`partial_import` post-level behavior.** The `partial_import=true` parameter causes the endpoint to insert valid cases and return invalid ones in the error payload. However, whether a case with one invalid post is inserted as a truncated case (without the failing post) or dropped entirely depends on where the validation failure occurs. In practice: a case-level validation failure (missing required field) drops the entire case; a post-level validation failure (malformed content) may drop only the post. Verify this behavior in your sandbox with a deliberately malformed test payload before relying on it for production. Log both inserted counts and error payloads on every batch.
- **`legacy_id` for cross-referencing and idempotency.** The bulk import accepts a `legacy_id` string field per case. Store the Freshchat `conversation_id` here. This is the cleanest anchor for idempotency checks — before re-running any batch, query `GET /api/v1/cases?legacy_id={conversation_id}` to check for existing records. Note: Kayako does not enforce `legacy_id` uniqueness at the API level, so duplicate imports are possible if you re-run without cleanup.
- **Bulk channel types are limited.** The bulk import endpoint only supports `MAIL` and `NOTE` as channel types. Preserve the original Freshchat channel identity (web chat, WhatsApp, etc.) in a custom case field or tag.
- **Rate limits are not publicly documented.** Kayako returns HTTP `429` with a `Retry-After` header. Start at 2–3 bulk requests per second and back off on every `429`. Aggressive pacing against the async bulk endpoint can queue jobs faster than Kayako processes them, causing delayed `FAILED` responses.
- **Default page size is 10.** When reading back imported data for validation, set `limit=100` explicitly. [Archived cases are excluded from default queries](https://clonepartner.com/blog/blog/kayako-to-freshdesk-migration-engineering-guide-2026) — append `archived=1` to include them.
- **Webhook/callback support for bulk jobs.** Kayako does not provide webhook callbacks on bulk job completion as of this writing. Polling `GET /api/v1/jobs/{id}` at 5-second intervals is the supported pattern.

> [!WARNING]
> **Verify timestamp behavior in your sandbox.** Always test `created_at` and `updated_at` preservation on your specific Kayako Helpdesk instance using a small batch before running production imports. Behavior can vary by instance configuration.

### Kayako Status IDs Are Instance-Specific

Kayako status IDs are assigned per instance and vary between deployments. Always call `GET /api/v1/cases/statuses` on your target Kayako instance to retrieve the actual ID values before building your transformation. The response schema is:

```json
{
  "data": [
    {"id": 1, "label": "NEW", "type": "NEW"},
    {"id": 2, "label": "OPEN", "type": "OPEN"},
    ...
  ]
}
```

Do not hardcode status IDs. The table below shows the Freshchat-to-Kayako status concept mapping only — the numeric IDs must be resolved from your instance at runtime:

| Freshchat Status | Kayako Status Type | Query to resolve ID |
|---|---|---|
| `new` | `NEW` | `GET /api/v1/cases/statuses` |
| `assigned` | `OPEN` | `GET /api/v1/cases/statuses` |
| `waiting` | `PENDING` | `GET /api/v1/cases/statuses` |
| `resolved` | `COMPLETED` | `GET /api/v1/cases/statuses` |

## Data Model Mapping: Freshchat → Kayako

### Field-Level Mapping

| Freshchat Field | Kayako Field | Notes |
|---|---|---|
| `conversation_id` | `legacy_id` | Store for cross-reference and idempotency checks |
| First message `created_at` | `created_at` | Bulk import only; verify in sandbox |
| Last message `created_at` | `updated_at` | Bulk import only; verify in sandbox |
| Conversation `status` | `status_id` | Query `GET /api/v1/cases/statuses` at runtime |
| `assigned_agent_id` | `assigned_agent_id` | Requires user pre-migration to complete |
| `assigned_group_id` | `assigned_team_id` | Requires team pre-creation in Kayako |
| Conversation properties | `field_values` | Map to pre-created custom case fields |
| Messages array | `posts` array | Flatten `message_parts` to HTML or plain text |
| Message `created_at` | Post `created_at` | Per-post timestamps (bulk endpoint only) |
| Message `actor_type` = `agent` | Post `creator_id` = Kayako agent user ID | |
| Message `actor_type` = `user` | Post `creator_id` = Kayako requester user ID | |
| Message `actor_type` = `bot` or `system` | Post `creator_id` = placeholder Kayako user | Create a "Bot/System" user in Kayako before import |
| Message attachments | Post `attachment_file_ids` | Upload to Kayako first; reference returned file IDs |
| Tags | `tags` | Comma-separated string |
| CSAT rating | Custom field, tag, or internal note | No native CSAT import field in Kayako |
| `interaction_id` | Custom field or migration note | Changes on conversation reopen; preserve all distinct values if reopen history matters for audit |

### Generating Case Subjects

Freshchat conversations have no subject field. Generate one programmatically from the first message:

```python
def generate_subject(messages):
    if not messages:
        return "Empty Chat Transcript"
    first_text = ""
    for part in messages[0].get("message_parts", []):
        if "text" in part:
            first_text = part["text"].get("content", "")
            break
    if not first_text:
        return "Chat Transcript"
    if len(first_text) > 50:
        return first_text[:47] + "..."
    return first_text
```

### Flattening Rich Messages

Freshchat messages can include structured `reply_parts`, collections, quick replies, and template content. Kayako posts are text or HTML. Use `ensure_ascii=False` when serializing non-ASCII content:

```python
import json

def flatten_message(msg):
    parts = []
    for part in msg.get("message_parts", []):
        if "text" in part:
            parts.append(part["text"]["content"])
        if "file" in part:
            parts.append("[attachment] " + part["file"]["name"])
        if "image" in part:
            parts.append("[image] " + part["image"]["url"])
    if msg.get("reply_parts"):
        parts.append("[Bot UI elements — original JSON preserved below]")
        parts.append(json.dumps(msg["reply_parts"], ensure_ascii=False))
    header = "[" + msg.get("actor_type", "unknown") + "]"
    return "\n".join([header] + parts)
```

A plain-text serialization of bot elements is more useful to agents than a partially preserved widget structure that Kayako cannot render. Explicitly label discarded interactive elements with `[Button: "Talk to agent"]` or similar so agents understand what was there.

## Step-by-Step Migration Pipeline

### Step 1: Pre-Migration Setup

1. **Generate API credentials** for both platforms. Freshchat: Admin → Settings → API Tokens. Kayako: use admin username/password for Basic Auth, or Admin → Apps → API Keys if your instance supports token auth.
2. **Inventory your Freshchat data.** Collect: total conversation count, total message count (estimate from raw reports), total contacts and agents, approximate attachment volume in GB, and oldest conversation date. If data older than 15 months exists, the raw reports API cannot cover it — you must use the per-conversation messages API for that date range.
3. **Provision a Kayako sandbox.** Request a sandbox instance from Kayako support or use a staging subdomain. Run all timestamp, `partial_import`, and channel-type tests against the sandbox before touching production. A realistic test dataset should include: one conversation with attachments, one bot-only conversation, one conversation reopened multiple times, and one conversation with non-ASCII characters.
4. **Pre-create Kayako infrastructure.** Create teams, custom case fields, case types, priorities, and status values in Kayako before import. Query `GET /api/v1/cases/statuses`, `GET /api/v1/departments`, `GET /api/v1/teams`, and `GET /api/v1/cases/fields` and record all IDs in a local config file.
5. **Build a user mapping table.** Extract all Freshchat agents (`GET /v2/agents`) and contacts (`GET /v2/contacts`) as separate lists. Create corresponding Kayako users via `POST /api/v1/bulk/users`. Store the mapping as `{freshchat_id: {type: "agent"|"contact", kayako_id: ...}}`. Map departed agents to a "Legacy Agent" placeholder account to avoid orphaned records. Create a "Bot/System" placeholder user for `actor_type` = `bot` and `system` messages.

### Step 2: Extract Freshchat Data

1. **Pull conversations** using paginated `GET /v2/conversations` calls (100 per page, sorted ascending by `created_time`).
2. **For each conversation, pull all messages** via `GET /v2/conversations/{id}/messages` (50 per page max). Run message fetches asynchronously. This is the bottleneck step — see runtime estimates below.
3. **Download all attachments immediately.** Do not defer to the transformation phase. Store locally keyed by `{message_id}_{filename}`.
4. **Export raw reports** for CSAT, resolution times, and agent assignments via `POST /v2/reports/raw/` in 24-hour windows, covering the full 15-month range available.
5. **Store everything in a staging database.** PostgreSQL works well. Create tables for conversations, messages, contacts, agents, and attachments with the raw JSON preserved alongside parsed fields. This allows re-running transformation without re-extraction.

### Step 3: Transform Data

1. **Flatten `message_parts`** into HTML for each message. Replace interactive elements with human-readable text: `[Button: "Talk to agent"]`, `[Quick reply: "Yes"]`.
2. **Generate subject lines** from the first message of each conversation using the function above.
3. **Map users** using the lookup table from Step 1. Flag any unmapped `actor_id` values — these indicate contacts or agents missing from your extraction.
4. **Map statuses, priorities, and types** to Kayako IDs resolved from your instance API.
5. **Upload attachments** to Kayako via the file upload endpoint. Receive back `file_id` values. Associate each `file_id` with the corresponding message record in your staging database.
6. **Build Kayako bulk payloads.** Each payload contains up to 200 cases with inline `posts`, timestamps, `legacy_id`s, and `attachment_file_ids`. Validate payload structure against the Kayako API schema before submission.

### Step 4: Load into Kayako

1. **Submit bulk case batches** of up to 200 via `POST /api/v1/bulk/cases?partial_import=true`.
2. **Poll job status** via `GET /api/v1/jobs/{id}` at 5-second intervals until each batch reports `COMPLETED` or `FAILED`.
3. **Log both successful inserts and error payloads.** Record the returned Kayako case IDs alongside the source `legacy_id` (Freshchat `conversation_id`) in your staging database.
4. **Reprocess failures** individually, inspecting the error payload to determine whether failure was at case level or post level.
5. **Pace your requests.** Start at 1 batch per 5 seconds. Back off on every `429`.

```python
import requests
import json
import time

KAYAKO_BASE = "https://{subdomain}.kayako.com/api/v1"
AUTH = ("admin@example.com", "password")

def submit_batch(cases_batch):
    resp = requests.post(
        f"{KAYAKO_BASE}/bulk/cases?partial_import=true",
        auth=AUTH,
        headers={"Content-Type": "application/json"},
        data=json.dumps({"cases": cases_batch})
    )
    if resp.status_code == 429:
        wait = int(resp.headers.get("Retry-After", 60))
        time.sleep(wait)
        return submit_batch(cases_batch)
    if resp.status_code == 202:
        job_url = resp.json()["data"]["resource_url"]
        return poll_job(job_url)
    raise Exception(f"Unexpected status {resp.status_code}: {resp.text}")

def poll_job(job_url):
    while True:
        resp = requests.get(job_url, auth=AUTH)
        data = resp.json()["data"]
        if data["status"] == "COMPLETED":
            return data
        if data["status"] == "FAILED":
            raise Exception(f"Job failed: {data}")
        time.sleep(5)
```

### Step 5: Delta Sync and Cutover

Freezing support operations for 5–9 days is not feasible. Use a delta sync strategy:

1. **Initial load:** Run the migration on all historical data up to a specific cutover timestamp (e.g., all conversations with `created_time` before Friday 00:00 UTC). Your team continues working in Freshchat.
2. **Delta sync:** On cutover day, query only conversations `created_time` or `updated_time` after that timestamp. The smaller volume finishes in hours.
3. **Cutover:** Update DNS records, website widgets, and routing to direct new traffic to Kayako. Team logs out of Freshchat and into Kayako.
4. **Final catch-up:** Run the delta script one more time to capture conversations updated during the switchover window.
5. **Read-only period:** Keep Freshchat active in read-only mode for at least 14 days before decommissioning. This preserves access for audit and any discovered migration gaps.

### Step 6: Validate

API calls returning success codes does not confirm data integrity. Run all of the following:

- **Count reconciliation:** Compare total conversations in Freshchat against total cases in Kayako. Compare total messages in your staging database against total posts in Kayako. A >1% discrepancy warrants investigation before cutover.
- **Timestamp spot-checks:** Sample 20+ cases and verify that `created_at` in Kayako matches the original Freshchat conversation `created_time`, not the date your migration script ran. This is the most common silent failure.
- **Attachment verification:** Sample 5–10% of conversations with attachments. Open each in Kayako and confirm files are accessible and correctly associated with the right post.
- **Thread usability checks:** Open a sample of bot-heavy conversations, reopened conversations, and multilingual conversations. Verify the flattened content is readable and correctly attributed by `actor_type`.
- **Multi-channel contact check:** For contacts with conversations across multiple Freshchat channels, verify that all conversations appear as separate Kayako cases linked to the same requester.
- **Archived case access:** Use `GET /api/v1/cases/{id}/posts?archived=1` for any resolved cases that Kayako has archived. Standard queries exclude archived cases.

## What Cannot Be Migrated

Some data and configuration will not transfer programmatically:

- **Freddy bot flows and configurations** — Must be rebuilt manually in Kayako or a third-party bot tool. As we note in our [Freshchat to Ada migration guide](https://clonepartner.com/blog/blog/freshchat-to-ada-migration-a-technical-guide), bot migration is always a redesign, never a direct conversion.
- **IntelliAssign routing rules** — Kayako uses its own assignment automation. Rebuild using Kayako Triggers and Monitors.
- **CSAT survey configuration** — Raw CSAT scores can be preserved as custom field values, tags, or internal notes (e.g., `[Historical Data: Customer rated this interaction 5/5]`). Survey logic must be rebuilt natively.
- **Canned responses / macros** — Must be manually recreated in Kayako's macro engine.
- **Widget customizations** — Kayako Messenger uses its own configuration interface.
- **Marketplace app integrations** — Freshchat marketplace apps must be replaced with Kayako equivalents or custom integrations.
- **Agent performance metrics and historical reports** — Export from Freshchat raw reports before decommissioning. This data does not transfer to Kayako reporting.
- **Rich interactive message elements** — Carousels, quick-reply buttons, and input controls lose interactivity. They are preserved as labeled text fallbacks only.

## Edge Cases and Failure Modes

- **Conversations with no messages.** Some conversations are created by automation or bots but contain zero messages. The Kayako bulk import requires at least one post per case. Filter these out during transformation or inject a placeholder post: `[No messages — automated conversation]`.
- **User creation race condition.** Bulk user import jobs run asynchronously. If your user import has not finished when you submit the first case batch, `requester_id` and `creator_id` references will fail validation. Always poll user import jobs to `COMPLETED` status before starting case import.
- **`partial_import` and post-level failures.** A post with malformed content (oversized payload, invalid encoding, null `creator_id`) may be dropped while the parent case is inserted. This produces a case in Kayako with fewer posts than in Freshchat — silent data loss. Log the full error payload from every job and reconcile post counts per case, not just case counts.
- **`legacy_id` is not enforced as unique.** Kayako's API does not prevent duplicate `legacy_id` values. Re-running a migration batch without cleanup creates duplicate cases. Build idempotency into your script: before submitting a batch, query `GET /api/v1/cases?legacy_id={conversation_id}` for a sample of records to detect prior runs.
- **Attachment URL expiration.** Freshchat signed attachment URLs expire. Download during extraction. If you discover expired URLs during transformation, you cannot recover those attachments without re-running extraction against the source (which may itself fail if the Freshchat conversation is old).
- **Conversation property schema access.** Freshchat's conversation property definition API is documented as available only for accounts that are part of Freshsales Suite. Even there, default properties (`group`, `agent`, `status`) are not returned. Capture the custom property schema manually from the Freshchat admin interface early in the project.
- **Freshchat `interaction_id` changes on reopen.** Each time a resolved conversation is reopened, Freshchat assigns a new `interaction_id` while keeping the same `conversation_id`. If reopen history matters for compliance or audit, store all distinct `interaction_id` values encountered in a custom field or migration note. Do not discard this data.
- **Multi-channel contact deduplication.** A single Freshchat contact with simultaneous or sequential conversations across web chat, WhatsApp, and email will appear as multiple distinct `conversation_id` records, all sharing the same `user_id`. These should map to separate Kayako cases under the same requester. Do not merge these threads — they represent different channel interactions. Verify that your user mapping correctly resolves the shared `user_id` to a single Kayako requester.

## Runtime Estimates

**Calculation basis for 15,000 conversations, ~8 messages average, 5% with attachments:**

| Phase | Calculation | Estimated time |
|---|---|---|
| Conversation list extraction | 150 API calls at 100 req/min | ~2 minutes |
| Message extraction (synchronous) | 15,000 conversations × avg 0.16 pages × 600ms/call + 429 backoff | **2–4 days** |
| Message extraction (async, 10 workers) | Same call volume, parallelized within rate limit | 6–12 hours |
| Attachment download (750 conversations) | Depends on file sizes and bandwidth | 2–8 hours |
| Transformation + staging | Message flattening, user mapping, payload build | 4–8 hours |
| Loading (Kayako bulk, 75 batches × 200 cases) | Job submission + polling | 2–4 hours |
| Validation + delta sync | Spot checks + catch-up run | 4–8 hours |
| **Total (synchronous extraction)** | | **5–9 days** |
| **Total (async extraction)** | | **2–4 days** |

Larger instances (50K+ conversations) or attachment-heavy datasets scale approximately linearly with conversation count. Extraction is always the bottleneck. Moving from synchronous to async message fetching is the single highest-impact optimization available.

## DIY vs. Managed Migration

**Handle it in-house if:**
- Fewer than 5,000 conversations
- No attachments or minimal attachment volume
- No custom fields requiring complex mapping
- You have a Python or Node.js developer with 2+ weeks available and comfortable with API debugging
- You have a staging environment to test against

**Use a managed service if:**
- 10,000+ conversations
- Attachment preservation is required
- You need the migration completed in under a week
- Historical timestamps must be exactly preserved and verified
- You cannot afford to discover silent data loss (missing posts, expired attachment URLs, encoding failures) after cutover

## Making the Right Call

A Freshchat to Kayako migration is a moderately complex API-to-API data pipeline. The structural advantages: Kayako's bulk import endpoint accepts historical timestamps and supports batch operations with inline posts — this is meaningfully better than target platforms that force current-date stamping or require separate post-creation calls. The structural disadvantages: Freshchat's per-conversation message extraction is rate-limited and sequential by default, rich message content requires careful flattening, and neither platform's rate limit behavior is fully documented.

The most operationally dangerous gaps are post-level silent failures under `partial_import=true`, expired attachment URLs discovered late in the pipeline, and `legacy_id` duplicate inserts from re-runs without idempotency checks.

Plan for extraction to be 50%+ of your total timeline. Always use async extraction. Use `legacy_id` on every imported case. Set `partial_import=true` on every batch and log every error payload. Reconcile post counts per case, not just total case counts. Keep Freshchat in read-only mode for at least 14 days after cutover.

For related migration guides, see our technical walkthroughs on [Freshchat to HappyFox migration](https://clonepartner.com/blog/blog/freshchat-to-happyfox-migration-a-technical-guide) and [Kayako to Freshdesk migration](https://clonepartner.com/blog/blog/kayako-to-freshdesk-migration-engineering-guide-2026). If you are evaluating Kayako as a target from other platforms, the [Zammad to Kayako guide](https://clonepartner.com/blog/blog/zammad-to-kayako-migration-the-technical-guide) covers a similar load pattern.

> Need help migrating from Freshchat to Kayako? ClonePartner specializes in API-to-API helpdesk migrations. Our team handles extraction orchestration, rate limit management, attachment preservation, and delta syncs. Book a 30-minute technical scoping call.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Is there a native migration tool from Freshchat to Kayako?

No. There is no built-in migration path, import wizard, or connector between Freshchat and Kayako. You must use the Freshchat REST API v2 for extraction and Kayako's REST API v1 bulk import endpoint for loading.

### Can I preserve original timestamps when migrating to Kayako?

Yes. Kayako's POST /api/v1/bulk/cases endpoint accepts created_at and updated_at fields in ISO-8601 format on both cases and posts. The single-case POST endpoint does not support timestamp overrides — only the bulk endpoint does. Verify behavior in your sandbox before running the full migration.

### How long does a Freshchat to Kayako migration take?

For a mid-size instance (15,000 conversations), expect 5–9 days total: 2–4 days for extraction (bottlenecked by per-conversation message fetching at 50 per page), 1–2 days for transformation, 1–2 days for loading, and 1 day for validation. Larger datasets scale linearly.

### What data cannot be migrated from Freshchat to Kayako?

Freddy bot flows, IntelliAssign rules, CSAT survey configurations, canned responses, widget customizations, marketplace app integrations, and agent performance analytics cannot be migrated programmatically. These must be rebuilt manually or exported separately from Freshchat raw reports.

### Can I export Freshchat data as a CSV and import it into Kayako?

Not for a complete migration. Freshchat allows CSV exports via raw reports for reporting purposes, but these files do not contain full message payloads, attachments, or relational data needed for a proper import. A full migration requires using the REST APIs of both platforms.
