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.
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:
- Kayako's archived ticket behavior. Cases closed for 30+ days are moved to an archive. Default API calls to
/api/v1/casesdo not include them. If you skip thearchived=1parameter, you silently lose your entire closed ticket history. (help.kayako.com) - Timestamp preservation on the Freshdesk side. The standard Freshdesk
POST /api/v2/ticketsendpoint stamps the current server time ascreated_at. It does not accept a historical timestamp through the public API. Freshworks staff have confirmed this in community responses. (community.freshworks.com) - 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_atlimitation 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)
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 |
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))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:
- Validate your field mapping by importing 100–200 representative tickets spanning different statuses, priorities, and custom field combinations.
- 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.
- Test notification behavior. Verify that your import approach (notes vs. replies) does not trigger outbound emails.
- Measure throughput. Time how long 1,000 tickets take to import at your plan's rate limit to estimate total migration duration.
- 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:
- Download each attachment from Kayako to temporary storage.
- 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.
- Delete the temp file after confirmed upload.
- 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)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
- 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_atandupdated_atvalues. File the request through Admin → Support → Contact Us and reference "data migration" and "timestamp preservation" explicitly. Response times vary; allow 5–10 business days. - Use private notes instead of replies for historical conversation entries. Notes created with
private: truedo 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 - Store original timestamps in custom fields. Create a custom date field called
original_created_aton Freshdesk tickets and populate it during import. This preserves the data for reporting even though the systemcreated_atreflects the import date. - Use a managed migration service that has established import pathways with Freshdesk, including timestamp preservation and notification suppression.
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)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
- 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.
- Delta sync loop. After the bulk sync completes, run incremental pulls from Kayako using
updated_atfilters to capture only tickets created or modified since the bulk extraction started. Push these deltas to Freshdesk. Run this every 2–4 hours, always includingarchived=1so closed history does not disappear from late passes. - 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.
- DNS/routing switch. Redirect your support email forwarding and widget endpoints from Kayako to Freshdesk.
- 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_updatedUse 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
- 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.
- Bulk delete via API. Use
DELETE /api/v2/tickets/:idto remove migrated tickets. This moves them to trash (recoverable for 30 days). For permanent deletion, follow withDELETE /api/v2/tickets/:id/restoreis not needed—tickets in trash auto-purge. Freshdesk rate limits apply to deletes as well. - Contact cleanup. Use
DELETE /api/v2/contacts/:idfor contacts created during migration. Contacts with associated tickets cannot be deleted until those tickets are removed first. - 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}")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_atwith the value stored in Freshdesk (either the system field or your customoriginal_created_atfield). - 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_idpointing 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']}")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.

