---
title: "Puzzel Case Management to Kayako Migration: Technical Guide"
slug: puzzel-case-management-to-kayako-migration-technical-guide
date: 2026-07-30
author: Wahab
categories: [Migration Guide]
excerpt: "A technical guide covering API extraction, data model mapping, and validation for migrating from Puzzel Case Management to Kayako — with architecture decisions and edge cases."
tldr: "Puzzel Case Management to Kayako migration is API-led. CSV exports strip thread history. Plan 2–4 weeks for 10K–50K tickets with full ETL, validation, and delta sync."
canonical: https://clonepartner.com/blog/puzzel-case-management-to-kayako-migration-technical-guide/
---

# Puzzel Case Management to Kayako Migration: Technical Guide


# Puzzel Case Management to Kayako Migration: Technical Guide

> [!NOTE]
> **TL;DR: Puzzel Case Management → Kayako Migration**
>
> A Puzzel Case Management to Kayako migration is a data-model translation, not a CSV copy job. Puzzel's CSV export captures ticket metadata but strips full message threads, notes, and attachments — you must extract through the Puzzel REST API (Swagger-documented per tenant as of September 2025). On the Kayako side, cases are created through the `/api/v1/cases.json` endpoint with Basic Auth and per-minute rate limits. Puzzel's Teams, Categories, Forms, and Form Fields do not map 1:1 to Kayako's Teams, Tags, Custom Fields, and Organizations — each requires structural mapping decisions. Smart Rules, SLAs, and automated workflows cannot be migrated programmatically. A typical mid-size migration (10,000–50,000 tickets) takes 2–4 weeks, roughly broken into: 3–4 days for planning and mapping, 3–5 days for extraction, 3–5 days for transformation and test loads, 2–3 days for production loading and delta sync, and 2–3 days for validation and cutover.

## What Is a Puzzel Case Management to Kayako Migration?

A **Puzzel Case Management to Kayako migration** moves tickets, contacts, organisations, categories, tags, notes, attachments, and conversation history from Puzzel Case Management into Kayako while preserving message threads, timestamps, and relational integrity.

<cite index="6-1">Puzzel Case Management is a Case/Ticket management tool that provides your contact centre or helpdesk with the ability to manage written interactions in a secure and efficient way.</cite> <cite index="6-2,6-3">Each email in the queue is converted into a support ticket and assigned a unique case/ticket ID which is then filtered, categorised and distributed to the right team or agent.</cite> It sits inside the broader Puzzel CX ecosystem — designed for contact centres that need skill-based routing, SLA tracking, and multi-channel queuing across email, SMS, and API integrations.

Kayako is a unified customer service platform that organises support around **conversations** (referred to as "cases" in its API — this guide uses "case" and "conversation" interchangeably to match Kayako's own documentation). <cite index="13-1,13-2,13-3">The Kayako API is a proprietary REST API implementation, based on resources — an object that the API endpoint fetches and/or manipulates.</cite> Kayako supports custom fields on conversations, users, and organizations, trigger-based automations, and a Help Center.

Teams move from Puzzel to Kayako for several concrete reasons:

- **Cost simplification:** Kayako consolidates ticketing without requiring a full contact centre stack, reducing per-seat costs for teams that don't use Puzzel's voice/omnichannel routing.
- **Conversation-centric UX:** Kayako's interface organises support around threaded conversations rather than Puzzel's queue-and-ticket paradigm.
- **Journey tracking:** Kayako natively tracks external customer events (website visits, purchases) directly in the conversation timeline — Puzzel requires external integration for this.
- **Organisational alignment:** Mergers or platform consolidations where Kayako is the target standard.

If the real requirement is marketing automation or CRM pipeline management, neither platform's documented core model is a leads-and-opportunities system. Those records should stay in a CRM or be represented only as lightweight context in Kayako custom fields.

## Core Differences in Data Model

Before writing a single migration script, you need to understand where the two platforms diverge architecturally. These differences drive every mapping decision.

| Concept | Puzzel Case Management | Kayako |
|---|---|---|
| **Primary record** | Ticket (with unique case/ticket ID) | Case / Conversation |
| **Customer grouping** | Organisation | Organization |
| **Agent grouping** | Teams (mapped to queues in Puzzel CC) | Teams |
| **Classification** | Categories (multi-tier, required-to-resolve) + Forms + Form Fields | Tags + Custom Fields (conversation, user, org) + Types (via forms) |
| **Structured data** | Form Fields (dropdown, text, date, nested dropdown) | Custom Fields (dropdown, text, numeric, date, checkbox, radio, yes/no) |
| **Notes** | Private/Public notes on tickets | Notes (pinned/unpinned) on cases, users, and organizations |
| **Routing** | Skill-based routing, inbound rules, event rules | Triggers and monitors (event-based automations) |
| **SLAs** | Multi-tier SLA targets (response, resolution) | SLA policies (configurable per priority/type) |
| **Automation** | Smart Rules, Inbound Rules, Event Rules, Response Mappings | Triggers, Monitors |
| **Channels** | Email, SMS, API ticket channels | Email, Messenger, Social, API |

The most significant structural difference: <cite index="69-1,69-2,69-3">Puzzel Categories are selected from ticket attributes, with multiple categories per ticket and a configurable number required for resolution.</cite> Kayako has no direct equivalent of this multi-tier category system. You'll need to flatten categories into Kayako tags, custom fields, or a combination — and that's a design decision, not a simple field copy. This is detailed in the Field-Level Mapping section below.

## Migration Approaches

There are five viable paths. Each carries distinct trade-offs on control, speed, and engineering cost.

### 1. Native CSV Export / Import

**How it works:** <cite index="49-1,49-2,49-3">In Puzzel, go to the Tickets page, use the Ticket Attributes widget to filter the Tickets List for only those tickets you wish to export — for example, filter by "Team: Customer Service" and "Time Period: Last Week".</cite> Select tickets and export to CSV. Kayako has no native CSV import UI, so you'd still need to parse CSVs and feed them into the Kayako API.

**When to use it:** Quick one-time snapshots of metadata for small datasets (<1,000 tickets). Not suitable for full conversation history.

**Pros:** No API credentials needed for extraction. Fast for small subsets. Useful as reconciliation checkpoints even if the main migration is API-led.

**Cons:** CSV exports from Puzzel include ticket metadata (status, category, team, priority) but **do not include full message threads, notes, or attachments**. Timestamps are often overridden by the import date. Kayako still requires API scripting on the load side.

**Scalability:** Small datasets only. No pagination control — you select tickets page by page.

**Complexity:** Low (extraction) / Medium (loading).

### 2. API-Based Migration (Puzzel API → Kayako API)

**How it works:** Extract tickets, contacts, organisations, categories, and full message threads from Puzzel's REST API. Transform data in-memory or via an intermediate data store. Load into Kayako using `POST /api/v1/cases.json`, `POST /api/v1/users.json`, and `POST /api/v1/organizations.json`.

<cite index="7-4,7-5,7-6">On the Puzzel side, API ticket channels require selecting an authentication level — Global (access to all tickets) or Organisation-specific — with Basic Token or OAuth Token authentication.</cite>

On the Kayako side, <cite index="30-6">to retrieve cases via an API call, you need an agent, admin, or collaborator role user account.</cite> <cite index="58-15,58-16">Use Basic Auth as the authentication type. By default, API calls return 10 results per page.</cite> <cite index="30-2">You can retrieve up to 100 conversations by setting a limit of 100.</cite>

**When to use it:** Any migration where you need full conversation history, attachments, and relational integrity. This is the recommended approach for production migrations.

**Pros:** Full control. Preserves message threads, notes, timestamps. Handles attachments. Supports incremental/delta loads.

**Cons:** Requires significant development effort. <cite index="32-2">Kayako API rate limits are defined per minute</cite> — you need throttling logic. Exact rate limits are not publicly documented and vary by plan; you must benchmark against your own Kayako tenant during UAT. Puzzel API documentation must be accessed from your tenant's Swagger UI. Script errors can result in duplicated data if not properly handled with idempotency checks.

**Scalability:** Enterprise-grade with proper batching and retry logic.

**Complexity:** High.

### 3. Third-Party Migration Tools

No major migration tool (Help Desk Migration, Import2, etc.) currently offers a verified direct Puzzel Case Management → Kayako connector. Both platforms are niche enough that third-party coverage is limited. Kayako's own support documentation points customers toward Help Desk Migration for some migration scenarios and notes the service can ingest from API, CSV, XML, or JSON when needed.

If a tool claims support, verify it handles full thread history, not just metadata. Confirm edge-case support before committing — complex Puzzel Forms and deep relational mappings are where generic tools break down.

**Complexity:** Low–Medium if available, but availability is the constraint.

### 4. Custom ETL Pipeline

**How it works:** Build an extract-transform-load pipeline using Python, Node.js, or a data engineering framework (Airflow, Prefect, AWS Glue, Azure Data Factory). Extract from Puzzel API, stage in a relational database or flat files, run transformation scripts, and load into Kayako API.

**When to use it:** Large-scale migrations (50,000+ tickets), complex transformations (category flattening, field splitting, multi-system merges), or when you need an auditable intermediate layer for compliance.

**Pros:** Full auditability. Replayable. Handles complex transformations cleanly. Intermediate staging enables validation before loading.

**Cons:** Highest engineering and infrastructure investment. Overkill for standard helpdesk migrations under 50,000 tickets.

**Scalability:** Best for enterprise volumes.

**Complexity:** High.

### 5. Middleware / Integration Platforms (Zapier, Make)

**How it works:** Use Zapier, Make, or Workato to connect Puzzel and Kayako via their APIs. Trigger-based flows extract records from one and push to the other.

**When to use it:** Ongoing sync of new tickets during a phased cutover, or very small one-time transfers. Not suitable for bulk historical migration.

**Pros:** Low code. Fast to set up for small volumes.

**Cons:** No native Puzzel Case Management connector on most platforms — you'd use generic HTTP/webhook modules. No bulk extraction. Rate limit and timeout risks. Extremely slow for historical data — one record at a time. High cost per task at volume.

**Scalability:** Small datasets only (<500 records for one-time; low-volume for ongoing sync).

**Complexity:** Low–Medium.

### Comparison Table

| Method | Best Fit | Full History | Attachments | Scalability | Eng Effort | Complexity |
|---|---|---|---|---|---|---|
| CSV Export/Import | Contacts & metadata only | ❌ | ❌ | Low | Low–Medium | Low |
| API-to-API | Most production migrations | ✅ | ✅ | High | High | High |
| Third-Party Tools | Standard setups (if available) | Varies | Varies | Medium | Low | Low–Medium |
| Custom ETL | Enterprise + compliance | ✅ | ✅ | High | Very High | High |
| Middleware (Zapier/Make) | Phased cutovers, ongoing sync | ❌ (new only) | Limited | Low | Low | Low–Medium |

### Recommendations by Scenario

- **Small business (<5,000 tickets), limited engineering:** CSV export for metadata plus manual backfill of critical threads, or engage a managed migration service.
- **Mid-market (5,000–50,000 tickets), one-time migration:** API-to-API with a Python or Node.js script. Budget 2–4 weeks.
- **Enterprise (50,000+ tickets), dedicated dev team:** Custom ETL with staging database, automated validation, and delta sync.
- **Ongoing sync (running both platforms in parallel):** API-to-API with a scheduled job. Middleware is too brittle for this at scale.

## Pre-Migration Planning

A successful migration is 80% planning and 20% execution. Start with a data audit, not a script.

### Data Audit Checklist

Inventory what you have in Puzzel and what you need in Kayako:

| Data Object | Audit Questions |
|---|---|
| **Tickets** | Total count? Status distribution (open, pending, on hold, resolved, closed)? Date range? |
| **Organisations** | How many? Custom attributes? Inactive ones to archive? |
| **Contacts/Customers** | Total count? Duplicates by email? Associated to organisations? |
| **Categories** | How many tiers? How many categories per tier? Required-to-resolve rules? |
| **Forms & Form Fields** | How many forms? What field types (dropdown, text, date, nested)? Unused fields? |
| **Tags** | Total unique tags? |
| **Notes** | Private vs public notes? Volume per ticket? |
| **Attachments** | Total storage size? File types? Per-ticket volume? Max individual file size? |
| **Templates** | How many? Actively used? |
| **Smart Rules / Event Rules** | How many? What triggers and actions? |
| **SLAs** | How many policies? Per-team or per-category? |

### Identify What Not to Migrate

Not everything needs to move. Common candidates for exclusion:

- Resolved/closed tickets older than 2–3 years (archive, don't migrate)
- Test tickets and internal-only records
- Unused categories or forms
- Disabled users and orphaned contacts
- Spam tickets

Remove dead data early, then define scope: full history, last 24 months, or active-only plus archive.

### Migration Strategy

- **Big bang:** Migrate everything in a single cutover window. Best for small datasets. Risk: if something fails, you're stuck between two systems.
- **Phased:** Migrate by team or category. Each phase is independently validated. Lower risk, longer timeline.
- **Incremental:** Migrate historical data first, then run a delta sync to capture new tickets created during the migration window. Recommended for live support operations.

For most helpdesks, Big Bang paired with a final delta sync is the safest way to avoid split-brain data scenarios. The pattern is: pilot → full test → delta sync → UAT → cutover.

### Risk Mitigation

Before you start:

- Name migration owners (technical lead + business stakeholder)
- Define schema freeze windows (at least two weeks before migration — no new custom fields, categories, or forms in Puzzel)
- Pre-approve fallback routing (where do tickets go if Kayako is down during cutover?)
- Decide what gets archived instead of migrated
- Document rollback criteria: what constitutes a failed cutover, who can revert routing, which batches are safe to rerun

## Data Model and Object Mapping

This is where the migration lives or dies. Get the mapping wrong, and everything downstream breaks.

> [!WARNING]
> **No clean 1:1 mapping exists** from Puzzel categories, forms, and form fields to Kayako forms and custom fields. Decide early what becomes a form, what becomes a typed custom field, what becomes a tag, and what stays in an external archive.

### Object-Level Mapping

| Puzzel Case Management | Kayako | Notes |
|---|---|---|
| Organisation | Organization | Near 1:1. Map custom attributes to Kayako org custom fields. Enable domain matching in Kayako to auto-associate future users. |
| Contact / Customer | User (customer role) | Map email, name, phone. Puzzel contact groups have no Kayako equivalent — use org or tags. Retain primary email as unique identifier. |
| Agent / User | User (agent/admin role) | Map team membership. |
| Team | Team | Near 1:1. |
| Ticket | Case / Conversation | Core record. Map status, priority, assignee, channel. Store Puzzel Case ID in a Kayako Custom Field (e.g., `legacy_puzzel_id`) for auditability. |
| Ticket Message (reply) | Post (case message) | Preserve sender, timestamp, HTML body. |
| Note (private) | Note | Kayako notes are agent-internal by default. Direct mapping. |
| Note (public) | Post | <cite index="5-6,5-7">Puzzel supports flagging notes as public or private — private notes are not exposed via API channels.</cite> Public Puzzel notes should be migrated as case posts in Kayako, not notes, to preserve customer-visible context. |
| Category | Tag or Custom Field | Puzzel categories are structured (multi-tier with dropdowns). Flatten to tags for simple use; map to custom dropdown fields for structured data. See detailed options below. |
| Form | Form (conversation type) | Kayako forms control which fields appear per conversation type. Rebuild manually. |
| Form Field | Custom Field (conversation) | Map field types: dropdown → dropdown, text → text, date → date. Nested dropdowns have no Kayako equivalent — flatten to separate fields. |
| Tag | Tag | Direct 1:1 mapping. |
| Attachment | Attachment | Binary transfer via API. Upload via `POST /api/v1/cases/{id}/attachments` (see Attachments section). |
| Smart Rule / Inbound Rule | Trigger | Cannot be migrated programmatically. Must be rebuilt manually. |
| SLA Policy | SLA Policy | Rebuild manually. |

### Field-Level Mapping Decisions

**Ticket Status:** Puzzel uses configurable statuses (typically: New, Open, Pending, On Hold, Resolved, Closed). Kayako uses: New, Open, Pending, Completed, Closed.

| Puzzel Status | Kayako Status | Notes |
|---|---|---|
| New | New | Direct map |
| Open | Open | Direct map |
| Pending | Pending | Direct map |
| On Hold | Pending | Kayako has no native "On Hold" — map to Pending and add a tag `on-hold` or create a custom field `hold_reason` to preserve context |
| Resolved | Completed | Kayako's "Completed" is the semantic equivalent |
| Closed | Closed | Direct map |

**Priority:** Both platforms support priority levels, but naming differs. Map explicitly:

| Puzzel Priority | Kayako Priority |
|---|---|
| Low | Low |
| Medium | Normal |
| High | High |
| Critical | Urgent |

**Categories → Tags/Custom Fields:** This is the hardest mapping decision. If Puzzel uses a two-tier category like `Query Type: Complaint > Billing`, you have three options:

1. **Flatten to a single tag:** `complaint-billing` — simplest, but loses the hierarchy and makes filtering less precise.
2. **Map to two separate custom dropdown fields** on the Kayako conversation (e.g., `category_tier_1: Complaint`, `category_tier_2: Billing`) — preserves the most structure but requires creating custom fields in Kayako before loading. Best for teams that use categories for reporting.
3. **Use only the leaf value** as a tag or custom field (`billing`) — loses parent context, only viable if leaf values are globally unique.

The right answer depends on whether agents actively filter and report by category hierarchy. Run a usage audit in Puzzel: if agents rarely filter by the top tier, option 3 suffices.

**Leads / Opportunities:** Neither vendor publicly documents a native lead object in the helpdesk layer. If you track leads via Puzzel Forms, map these to Kayako Cases with a custom `Type` field set to "Lead" — or keep them in a CRM and use Kayako custom fields for read-only context.

## Migration Architecture

The reliable pattern is **Extract → Stage → Transform → Load**.

### Data Flow

```
Puzzel API (Extract) → Staging DB (Raw JSON) → Transform Layer → Kayako API (Load)
```

### Extraction from Puzzel

<cite index="1-1">Puzzel's API documentation is now accessible via Swagger UI from the Help menu within your tenant.</cite> This is your primary reference for available endpoints. Puzzel does not publish a public API reference for Case Management — all endpoint paths and payload structures must be confirmed from your tenant's Swagger documentation.

<cite index="67-4">The API supports: creating, reading, modifying, and deleting tickets and all associated metadata; listing tickets with search criteria; filtering by date range; listing categories and forms associated to tickets; organisation list; and tag list.</cite>

Key extraction endpoints (accessed via your tenant's Swagger):

- `GET /tickets` — paginated ticket list with filters (including `modified_after` for delta sync — see Delta Sync section)
- `GET /tickets/{id}` — full ticket detail including messages, notes, and attachment metadata
- `GET /organisations` — organisation list
- `GET /tags` — tag list
- `GET /categories` — category definitions
- `GET /forms` — form definitions with fields

**Authentication headers:** For Basic Token, include `Authorization: Basic {base64(username:token)}` in your request headers. For OAuth Token, include `Authorization: Bearer {access_token}`. The specific token type depends on how your API ticket channel is configured in Puzzel.

**Pagination:** Puzzel's ticket list endpoint supports offset/limit pagination. The default page size and maximum vary by tenant configuration — start with `?limit=50&offset=0` and increment. If you have very large classification trees, Puzzel's June 2025 release added category-choice pagination with support for fetching up to 1,000 choices per request using `?per_page=1000`.

Store raw JSON payloads in an intermediate staging database (PostgreSQL or SQLite for small migrations, MongoDB for complex nested structures). This prevents re-querying the Puzzel API if a transformation step fails and gives you an auditable snapshot of the source data.

### Loading into Kayako

For **writing** data:

- `POST /api/v1/users.json` — create users
- `POST /api/v1/organizations.json` — create organizations
- `POST /api/v1/cases.json` — create cases
- `POST /api/v1/cases/{id}/notes.json` — add notes
- `POST /api/v1/cases/{id}/posts.json` — add messages/replies
- `POST /api/v1/cases/{id}/attachments` — upload attachments (multipart form data)

<cite index="32-2">Kayako API rate limits are defined per minute.</cite> <cite index="32-1">If you exceed the rate limit, you get HTTP 429 with a `Retry-After` header holding the number of seconds before you can retry.</cite> Exact limits are not publicly documented and vary by plan. During UAT, run a burst test against your Kayako tenant to determine your actual ceiling — a common approach is to send sequential requests and record when 429s first appear.

**User deduplication behavior:** When you `POST /api/v1/users.json` with an email that already exists, Kayako returns a `409 Conflict` error rather than creating a duplicate. Your script must handle this: catch the 409, extract the existing user's ID from the error response or a follow-up `GET /api/v1/users.json?email={email}` query, and use that ID for subsequent operations. This is critical for retry safety — if a migration batch fails partway through and you re-run it, users created in the first pass won't be duplicated.

> [!WARNING]
> **Kayako Classic vs Kayako (New):** If you're migrating to Kayako Classic (the self-hosted version), note that <cite index="34-2">there's no API rate limit implemented in Kayako Classic.</cite> The API surface also differs significantly — Classic uses XML-based endpoints like `/Tickets/Ticket`. Confirm which Kayako version you're targeting before writing any integration code. Everything in this guide assumes Kayako (New) unless explicitly stated otherwise.

### Authentication

**Puzzel:** <cite index="7-6">Select your Token Type — Basic Token or OAuth Token — when creating an API ticket channel.</cite>

**Kayako:** <cite index="58-8,58-9">When sending an API call with Postman, add your credentials (Kayako username and password) to the Authorization tab. Use Basic Auth as the authentication type.</cite> The header format is `Authorization: Basic {base64(email:password)}`.

### Handling Large Datasets

- **Puzzel:** Paginate through ticket lists using offset/limit parameters via the Swagger-documented endpoints. Extract in batches of 50–100. Store each batch's raw JSON immediately before processing.
- **Kayako:** <cite index="30-17,30-18">By default, API calls return 10 results per page. You can increase to 100 per page using the limit argument.</cite> For writes, there is no bulk create endpoint — cases, users, and organizations must be created one at a time. Implement a single-threaded write loop with exponential backoff and jitter.
- **CSV reconciliation:** Use Puzzel CSV exports as audit checkpoints even if the main migration is API-led. Compare ticket counts and status distributions between CSV and API extractions to catch extraction gaps.

### Delta Sync Implementation

Delta sync captures tickets created or modified in Puzzel during the migration window. The implementation depends on Puzzel's API filtering capabilities:

1. **Timestamp-based polling (preferred):** Use `GET /tickets?modified_after={iso_datetime}` to fetch only tickets modified since your last extraction. Store the timestamp of your last successful extraction run. After the historical load completes, run delta sync repeatedly (e.g., every 30 minutes) until the cutover moment.

2. **Full re-extraction with diff:** If Puzzel's API doesn't support `modified_after` filtering on your tenant, extract the full ticket list and compare IDs and `updated_at` timestamps against your staging database. Process only new or changed records.

3. **Cutover sequence:**
   - T-24h: Final delta sync before cutover
   - T-0: Freeze Puzzel (stop accepting new tickets by disabling inbound channels)
   - T+15min: Run final delta sync
   - T+30min: Validate delta sync results
   - T+45min: Switch DNS/email routing to Kayako
   - T+1h: Confirm new tickets arriving in Kayako

The goal is to minimize the window where tickets might be created in Puzzel but not yet synced to Kayako.

## Step-by-Step Migration Process

### Step 1: Extract Data from Puzzel

Extract in dependency order — this is critical because downstream records reference upstream IDs:

1. **Organisations** — must exist before tickets reference them
2. **Contacts / Customers** — linked to organisations
3. **Categories, Forms, Form Fields** — structural metadata
4. **Tags** — flat list
5. **Tickets** — with full message threads, notes, and attachments

For each ticket, extract:

- Ticket metadata (status, priority, team, assignee, created/updated timestamps)
- All messages in thread order (sender, body HTML, timestamp)
- Notes (with public/private flag)
- Category selections (all tiers)
- Form field values
- Attachments (binary content + metadata: filename, MIME type, size)

### Step 2: Transform Data

- Map Puzzel status values to Kayako status values (including On Hold → Pending + tag)
- Flatten multi-tier categories to tags or custom field values
- Convert Puzzel Form Fields to Kayako custom field payloads
- Normalise email addresses (lowercase, trim whitespace)
- Deduplicate contacts by email (merge records, keep the most complete profile)
- Sanitize HTML content and replace inline image CID references with hosted URLs (see CID Mapping section)
- Convert attachment references to multipart form data for Kayako upload
- Generate the ID mapping table skeleton

### Step 3: Pre-Create Structure in Kayako

Before loading data, manually or programmatically create:

- Teams (matching Puzzel team names)
- Custom fields (conversation, user, organization) — including `legacy_puzzel_id` (text), `original_created_at` (date), and any fields mapped from Puzzel Forms
- Forms (conversation types) — matching Puzzel Forms
- SLA policies — rebuilt from Puzzel's SLA configuration
- Tags (can be auto-created on first use in Kayako)

<cite index="39-4,39-5,39-6,39-7">Kayako supports organization fields for tracking data about supported organizations, and user fields for adding detail to both staff and customer profiles — with options for customer visibility and editability.</cite>

### Step 4: Load Data into Kayako

Load in strict dependency order:

1. **Organizations** → capture Kayako IDs, store in mapping table
2. **Users** (customers and agents) → associate with organizations, handle 409 Conflict for existing emails
3. **Cases** → create with subject, status, priority, assignee, requester, type, custom fields (including `legacy_puzzel_id` and `original_created_at`)
4. **Posts** (messages) → add to each case in chronological order
5. **Notes** → add to each case (private notes only; public notes become posts)
6. **Attachments** → attach to relevant posts or cases

If you create a Case before the User exists, Kayako will reject the payload or assign it to a default admin — so ordering is non-negotiable.

### Step 5: Handle Timestamp Preservation

Kayako's API behavior on `created_at` is version-dependent. Test this explicitly against your Kayako tenant before the production run:

1. Create a test case via `POST /api/v1/cases.json` with a `created_at` field set to a past date (e.g., `"created_at": "2023-01-15T10:30:00Z"`).
2. Retrieve the case via `GET /api/v1/cases/{id}.json` and check whether `created_at` reflects your specified date or the API call time.

**If the API accepts `created_at` overrides:** Include the original Puzzel creation timestamp in the case creation payload.

**If the API ignores `created_at` (most common with Kayako New):** Store the original Puzzel creation date in a custom field (`original_created_at`, type: date). This preserves timeline accuracy for reporting and agent reference. The Kayako `created_at` will reflect the migration date, which is acceptable as long as agents know to reference the custom field for the true ticket age.

### Step 6: Rebuild Relationships

Maintain an ID mapping table (`puzzel_id → kayako_id`) for every object type. Use this to:

- Link users to organizations
- Link cases to requesters and assignees
- Link posts to the correct case
- Associate attachments to the right post

Store `puzzel_id`, `kayako_id`, `object_type`, `migration_timestamp`, and `migration_batch_id` for every record. This mapping table is critical for the delta sync, rollback, selective deletion, and post-migration troubleshooting.

### Step 7: Validate and Delta Sync

Run a delta sync to capture tickets created during the migration window (see Delta Sync Implementation above). Validate before cutover (see the Validation section below).

### Code Example: Creating a Case and Adding Posts

```python
import requests
import time
import logging

logger = logging.getLogger(__name__)

KAYAKO_DOMAIN = "yourcompany.kayako.com"
KAYAKO_EMAIL = "admin@yourcompany.com"
KAYAKO_PASSWORD = "your-password"
BASE_URL = f"https://{KAYAKO_DOMAIN}/api/v1"
AUTH = (KAYAKO_EMAIL, KAYAKO_PASSWORD)

def create_case(subject, requester_id, contents, assignee_id=None,
                tags=None, custom_fields=None, max_retries=3):
    url = f"{BASE_URL}/cases.json"
    payload = {
        "subject": subject,
        "requester_id": requester_id,
        "channel": "MAIL",
        "contents": contents,
    }
    if assignee_id:
        payload["assigned_agent_id"] = assignee_id
    if tags:
        payload["tags"] = tags
    if custom_fields:
        payload["custom_fields"] = custom_fields

    for attempt in range(max_retries):
        response = requests.post(url, json=payload, auth=AUTH)

        if response.status_code == 429:
            retry_after = int(response.headers.get("Retry-After", 60))
            logger.warning(f"Rate limited. Retrying after {retry_after}s (attempt {attempt+1})")
            time.sleep(retry_after)
            continue

        if response.status_code >= 500 and attempt < max_retries - 1:
            wait = (2 ** attempt) + (time.time() % 1)  # exponential backoff + jitter
            logger.warning(f"Server error {response.status_code}. Retrying after {wait:.1f}s")
            time.sleep(wait)
            continue

        if response.status_code >= 400:
            logger.error(f"Failed to create case: {response.status_code} {response.text}")
            return None  # Add to dead-letter queue

        response.raise_for_status()
        return response.json()["data"]

    logger.error(f"Exhausted retries for case: {subject}")
    return None

def add_post_to_case(case_id, contents, creator_id, max_retries=3):
    url = f"{BASE_URL}/cases/{case_id}/posts.json"
    payload = {
        "contents": contents,
        "creator_id": creator_id,
        "source": "MAIL"
    }
    for attempt in range(max_retries):
        response = requests.post(url, json=payload, auth=AUTH)

        if response.status_code == 429:
            retry_after = int(response.headers.get("Retry-After", 60))
            time.sleep(retry_after)
            continue

        if response.status_code >= 500 and attempt < max_retries - 1:
            time.sleep((2 ** attempt) + (time.time() % 1))
            continue

        if response.status_code >= 400:
            logger.error(f"Failed to add post to case {case_id}: {response.status_code} {response.text}")
            return None

        response.raise_for_status()
        return response.json()["data"]

    return None

def upload_attachment(case_id, filepath, filename, mime_type, max_retries=3):
    """Upload attachment to a case via multipart form data."""
    url = f"{BASE_URL}/cases/{case_id}/attachments"
    for attempt in range(max_retries):
        with open(filepath, 'rb') as f:
            files = {'file': (filename, f, mime_type)}
            response = requests.post(url, files=files, auth=AUTH)

        if response.status_code == 429:
            retry_after = int(response.headers.get("Retry-After", 60))
            time.sleep(retry_after)
            continue

        if response.status_code >= 500 and attempt < max_retries - 1:
            time.sleep((2 ** attempt) + (time.time() % 1))
            continue

        if response.status_code >= 400:
            logger.error(f"Failed to upload attachment to case {case_id}: "
                        f"{response.status_code} {response.text}")
            return None

        response.raise_for_status()
        return response.json()["data"]

    return None

def find_or_create_user(email, full_name, org_id=None):
    """Create user, handling 409 Conflict for existing emails."""
    url = f"{BASE_URL}/users.json"
    payload = {"email": email, "full_name": full_name}
    if org_id:
        payload["organization_id"] = org_id

    response = requests.post(url, json=payload, auth=AUTH)

    if response.status_code == 409:
        # User exists — look up by email
        search_url = f"{BASE_URL}/users.json?email={email}"
        search_resp = requests.get(search_url, auth=AUTH)
        search_resp.raise_for_status()
        users = search_resp.json().get("data", [])
        if users:
            return users[0]
        logger.error(f"409 on user create but email lookup failed: {email}")
        return None

    if response.status_code >= 400:
        logger.error(f"Failed to create user {email}: {response.status_code} {response.text}")
        return None

    response.raise_for_status()
    return response.json()["data"]

# Example usage
user = find_or_create_user("customer@example.com", "Jane Smith", org_id=42)
if user:
    case = create_case(
        subject="Login Issue (Migrated)",
        requester_id=user["id"],
        contents="User cannot log into the portal.",
        custom_fields={
            "legacy_puzzel_id": "CASE-9921",
            "original_created_at": "2024-03-15T09:22:00Z"
        }
    )
```

> [!TIP]
> **Error handling:** Always log the full response body on failures — Kayako returns structured error objects with `code` and `message` fields. Store failed records in a dead-letter queue (a JSON file or database table) rather than stopping the entire migration. Review and retry failed records after the main batch completes.

### Full Migration Script Outline

A production migration script typically has these modules:

```python
# migration.py — Puzzel Case Management to Kayako Migration
import logging
import json
import sqlite3
from datetime import datetime

logging.basicConfig(level=logging.INFO, filename="migration.log")
logger = logging.getLogger(__name__)

# --- Configuration ---
PUZZEL_BASE_URL = "https://your-tenant.puzzel-ticketing.com/api"
PUZZEL_TOKEN = "your-puzzel-api-token"  # Base64(username:token) for Basic
KAYAKO_DOMAIN = "yourcompany.kayako.com"
KAYAKO_AUTH = ("admin@yourcompany.com", "password")

# --- ID Mapping Store (SQLite for durability) ---
db = sqlite3.connect("migration_map.db")
db.execute("""CREATE TABLE IF NOT EXISTS id_map (
    object_type TEXT, puzzel_id TEXT, kayako_id TEXT,
    batch_id TEXT, migrated_at TEXT,
    PRIMARY KEY (object_type, puzzel_id))""")
db.execute("""CREATE TABLE IF NOT EXISTS dead_letter (
    object_type TEXT, puzzel_id TEXT, payload TEXT,
    error TEXT, attempted_at TEXT)""")

def store_mapping(object_type, puzzel_id, kayako_id, batch_id):
    db.execute("INSERT OR REPLACE INTO id_map VALUES (?,?,?,?,?)",
               (object_type, str(puzzel_id), str(kayako_id),
                batch_id, datetime.utcnow().isoformat()))
    db.commit()

def get_kayako_id(object_type, puzzel_id):
    row = db.execute("SELECT kayako_id FROM id_map WHERE object_type=? AND puzzel_id=?",
                     (object_type, str(puzzel_id))).fetchone()
    return row[0] if row else None

# --- Step 1: Extract from Puzzel ---
def extract_organisations():
    """Paginate through Puzzel organisations endpoint.
    Headers: Authorization: Basic {PUZZEL_TOKEN}
    Endpoint: GET {PUZZEL_BASE_URL}/organisations?limit=50&offset=0
    """
    pass

def extract_contacts():
    """Paginate through Puzzel contacts."""
    pass

def extract_tickets(batch_size=50, modified_after=None):
    """Paginate through tickets. For each, fetch messages, notes, attachments.
    For delta sync, pass modified_after as ISO datetime string.
    Endpoint: GET {PUZZEL_BASE_URL}/tickets?limit={batch_size}&offset=0
              &modified_after={modified_after}
    """
    pass

# --- Step 2: Transform ---
def map_status(puzzel_status):
    STATUS_MAP = {
        "New": "NEW", "Open": "OPEN", "Pending": "PENDING",
        "On Hold": "PENDING",  # Add on-hold tag separately
        "Resolved": "COMPLETED", "Closed": "CLOSED"
    }
    return STATUS_MAP.get(puzzel_status, "OPEN")

def map_priority(puzzel_priority):
    PRIORITY_MAP = {
        "Low": "LOW", "Medium": "NORMAL",
        "High": "HIGH", "Critical": "URGENT"
    }
    return PRIORITY_MAP.get(puzzel_priority, "NORMAL")

def flatten_categories(categories):
    """Convert multi-tier categories to tags or custom field values."""
    return ["-".join(cat["path"]).lower().replace(" ", "-") for cat in categories]

def needs_on_hold_tag(puzzel_status):
    return puzzel_status == "On Hold"

# --- Step 3: Load into Kayako ---
def create_kayako_org(name, custom_fields=None):
    """POST /api/v1/organizations.json with rate limit handling."""
    pass

def create_kayako_user(email, full_name, org_id=None):
    """POST /api/v1/users.json — handle 409 Conflict for existing emails."""
    pass

def create_kayako_case(subject, requester_id, contents, status,
                        priority, tags, custom_fields):
    """POST /api/v1/cases.json with retry logic."""
    pass

def add_kayako_post(case_id, contents, creator_id, created_at):
    """POST /api/v1/cases/{id}/posts.json — add message to case."""
    pass

def add_kayako_note(case_id, body, creator_id):
    """POST /api/v1/cases/{id}/notes.json — add internal note."""
    pass

def upload_kayako_attachment(case_id, filepath, filename, mime_type):
    """POST /api/v1/cases/{id}/attachments — multipart upload."""
    pass

# --- Step 4: Orchestrate ---
def run_migration(batch_id="batch_001", modified_after=None):
    logger.info(f"Starting migration batch {batch_id}...")

    # Phase 1: Organisations
    for org in extract_organisations():
        existing = get_kayako_id("org", org["id"])
        if existing:
            continue  # Idempotent — skip if already migrated
        kayako_org = create_kayako_org(org["name"], org.get("custom_attributes"))
        if kayako_org:
            store_mapping("org", org["id"], kayako_org["id"], batch_id)

    # Phase 2: Users
    for contact in extract_contacts():
        existing = get_kayako_id("user", contact["id"])
        if existing:
            continue
        org_id = get_kayako_id("org", contact.get("organisation_id"))
        kayako_user = create_kayako_user(contact["email"], contact["name"], org_id)
        if kayako_user:
            store_mapping("user", contact["id"], kayako_user["id"], batch_id)

    # Phase 3: Tickets → Cases
    for ticket in extract_tickets(modified_after=modified_after):
        existing = get_kayako_id("ticket", ticket["id"])
        if existing:
            continue
        requester_id = get_kayako_id("user", ticket["customer_id"])
        if not requester_id:
            logger.error(f"No Kayako user for Puzzel customer {ticket['customer_id']}")
            continue

        tags = flatten_categories(ticket.get("categories", []))
        tags.extend(ticket.get("tags", []))
        if needs_on_hold_tag(ticket["status"]):
            tags.append("on-hold")

        custom_fields = ticket.get("form_fields", {})
        custom_fields["legacy_puzzel_id"] = str(ticket["id"])
        custom_fields["original_created_at"] = ticket.get("created_at")

        case = create_kayako_case(
            subject=ticket["subject"],
            requester_id=requester_id,
            contents=ticket["messages"][0]["body"],
            status=map_status(ticket["status"]),
            priority=map_priority(ticket["priority"]),
            tags=tags,
            custom_fields=custom_fields
        )
        if not case:
            continue
        store_mapping("ticket", ticket["id"], case["id"], batch_id)

        # Phase 4: Messages (skip first — already used as case contents)
        for msg in ticket["messages"][1:]:
            creator_id = get_kayako_id("user", msg["sender_id"])
            add_kayako_post(case["id"], msg["body"], creator_id, msg["created_at"])

        # Phase 5: Notes
        for note in ticket.get("notes", []):
            creator_id = get_kayako_id("user", note["user_id"])
            if note["is_private"]:
                add_kayako_note(case["id"], note["body"], creator_id)
            else:
                add_kayako_post(case["id"], note["body"], creator_id, note["created_at"])

        # Phase 6: Attachments
        for attachment in ticket.get("attachments", []):
            upload_kayako_attachment(
                case["id"], attachment["local_path"],
                attachment["filename"], attachment["mime_type"]
            )

    logger.info(f"Migration batch {batch_id} complete.")

if __name__ == "__main__":
    run_migration()
```

## Edge Cases and Challenges

Migrations are rarely straightforward. Anticipate these specific hurdles:

### Inline Images (CID Mapping)

Puzzel emails often contain inline images referenced by Content-ID (`cid:image001.png`). When migrating to Kayako:

1. Download the inline image from Puzzel's API (the message response includes attachment/inline-image URLs)
2. Upload the image to Kayako as an attachment, capturing the returned URL
3. Run a regex replace on the HTML body to swap `cid:` references with the new Kayako-hosted URL

```python
import re

def replace_cid_references(html_body, cid_url_map):
    """Replace cid: references with Kayako-hosted URLs.
    cid_url_map: {"image001.png": "https://yourcompany.kayako.com/attachments/abc123"}
    """
    for cid, url in cid_url_map.items():
        html_body = re.sub(
            rf'cid:{re.escape(cid)}',
            url,
            html_body,
            flags=re.IGNORECASE
        )
    return html_body
```

Use Puzzel's API-exposed inline-image links rather than trying to parse raw MIME email.

### Duplicate Contacts

Puzzel may have duplicate contacts (same person with different email addresses, or different contact records with the same email). Deduplicate in your staging database before loading:

- Same email, different names: merge to a single record, keeping the most complete profile
- Same person, different emails (e.g., personal and work): decide whether to merge (losing one email as primary) or create two Kayako users

Kayako will return `409 Conflict` on duplicate email creation — your `find_or_create_user` function must handle this gracefully.

### Multi-Tier Categories

Puzzel supports categories like `Query Type > Billing > Refund Request`. Kayako has no hierarchical tag or field system. The three mapping options were detailed above (flatten to tag, split to multiple custom dropdowns, or use leaf only). Additional considerations:

- If Puzzel enforces more than 3 category tiers, the number of required Kayako custom dropdown fields may become unwieldy — consider flattening the deepest tiers
- Run a distinct count on category paths before deciding: if there are 200+ unique leaf values, tags may be more manageable than a dropdown with 200 options

### Timestamp Preservation

As detailed in Step 5, Kayako may not accept `created_at` overrides on case creation. Test this early. The fallback — storing original timestamps in a custom field — is reliable but means Kayako's built-in "sort by created date" won't reflect the original timeline. Document this limitation for agents.

### Public vs Private Notes

<cite index="5-5,5-6,5-7">When adding a note to a Puzzel ticket via API, it can be flagged as public or private — private notes are not exposed via API channels.</cite> In Kayako, notes are agent-internal by default. **Public notes from Puzzel must be migrated as case posts (messages) in Kayako, not notes** — otherwise customer-visible context is lost. Tag migrated public notes with a consistent prefix (e.g., `[Migrated Note]`) so agents can distinguish them from original messages.

### Attachment Handling

Kayako enforces attachment size limits per API request. The exact limit depends on your Kayako plan and server configuration — verify against your tenant's documentation or by testing with progressively larger files. Puzzel itself warns that outbound emails over 40MB can fail into ERROR status, so most Puzzel attachments will be under 40MB.

For files exceeding Kayako's limit:
1. Host them externally (e.g., AWS S3 bucket with time-limited presigned URLs)
2. Append a secure link to the Kayako case as a note: "Large attachment: [filename] — [link]"
3. Log all oversized files for post-migration review

### Merged or Linked Tickets

If your Puzzel instance uses ticket linking (parent/child relationships), Kayako doesn't have a native ticket linking feature. Preserve these relationships as notes on the target case: "Linked to original Puzzel ticket #12345 (Kayako case #{kayako_id})." Use the ID mapping table to include the Kayako case ID if the linked ticket has already been migrated.

### API Failures and Retries

<cite index="32-1">If you exceed Kayako's rate limit, you get HTTP 429 with a `Retry-After` header.</cite> Implement exponential backoff with jitter — never retry immediately:

```python
def backoff_delay(attempt, base=2, max_delay=120):
    """Exponential backoff with jitter. Returns seconds to wait."""
    import random
    delay = min(base ** attempt + random.uniform(0, 1), max_delay)
    return delay
```

Handle 500 errors with retry logic (up to 3 retries per request). Keep a dead-letter queue (database table or JSON file) for records that fail after all retries — these need human review.

## Limitations and Constraints

Be explicit with stakeholders about what cannot be migrated programmatically.

### What Kayako Cannot Replicate from Puzzel

- **Multi-tier category system:** Kayako has flat tags and simple custom fields. No hierarchical classification. Best approximation: multiple custom dropdown fields, one per tier.
- **Skill-based routing with contact centre integration:** Puzzel's deep integration with Puzzel Contact Centre for voice/omnichannel routing has no Kayako equivalent. Kayako uses trigger-based assignment, which is less granular.
- **Form-per-team enforcement:** <cite index="69-17,69-18">Puzzel enforces "Force Category Choice to Resolve" at the team level, requiring agents to select categories before resolving.</cite> Kayako has no direct equivalent — you can approximate this with required custom fields on conversation forms, but enforcement isn't as strict.
- **Event Rules and Response Mappings:** <cite index="10-3,10-4">Puzzel's API Response Mapping enables automated enrichment of ticket attributes from webhook responses.</cite> Kayako has no equivalent feature. You'd need to build external webhook handlers that update Kayako cases via API.
- **Automations and Macros:** Puzzel routing rules, SLAs, and macros cannot be exported via API. These must be manually rebuilt in Kayako's trigger and monitor engine.
- **Reporting History:** Puzzel analytics and agent performance metrics do not migrate. <cite index="67-11">Puzzel tracks metrics including SLA analysis, team performance, ticket performance per team, per user, and per channel.</cite> Export historical reports to CSV for your data warehouse before decommissioning Puzzel.

### Kayako API Limitations

- **No bulk create:** Cases, users, and organizations must be created one at a time via individual POST requests.
- **Rate limits per minute:** Exact limits are not publicly documented; they vary by plan. Monitor 429 responses and benchmark your tenant during UAT.
- **Pagination cap:** 100 results per page maximum for GET requests.
- <cite index="30-4,30-5">There is no direct way to export all conversations from Kayako's UI — extraction requires API usage.</cite> This matters for validation and rollback.
- **No `created_at` override (Kayako New):** Most Kayako New tenants do not accept `created_at` in POST payloads — the API sets it to the request time. Confirm against your tenant.

## Validation and Testing

Do not assume a 200 OK response means the data is correct. Implement a rigorous validation phase.

### Record Count Comparison

After loading, compare counts for every object type:

| Object | Puzzel Count | Kayako Count | Match? | Notes |
|---|---|---|---|---|
| Organizations | X | X | ✅/❌ | |
| Users (customers) | X | X | ✅/❌ | May differ if duplicates were merged |
| Users (agents) | X | X | ✅/❌ | |
| Cases (tickets) | X | X | ✅/❌ | Account for intentionally skipped records |
| Posts (messages) | X | X | ✅/❌ | |
| Notes | X | X | ✅/❌ | Public notes become posts — adjust counts |
| Attachments | X | X | ✅/❌ | Oversized files logged separately |
| Tags | X | X | ✅/❌ | |

Account for intentionally skipped records (spam, archived tickets, merged duplicates) when counts differ. Document the reason for every discrepancy.

### Field-Level Validation

For a random sample of 50–100 tickets:

- Verify subject, status, priority, and assignee match
- Confirm all messages are present in correct chronological order
- Check custom field values (especially `legacy_puzzel_id` and category-mapped fields)
- Verify attachments are downloadable and not corrupted (compare file sizes)
- Confirm organisation association
- Verify inline images render correctly (CID references replaced)

### Sampling Strategy

Don't validate only recent tickets. Sample across:

- Different teams (at least 5–10 tickets per team)
- Different date ranges (oldest, newest, and mid-range)
- Tickets with many messages (>10 replies)
- Tickets with large or multiple attachments
- Tickets with all category tiers populated
- Tickets with internal notes
- Tickets that were "On Hold" in Puzzel
- Tickets with inline images

### UAT Process

Have 2–3 agents from each team review their migrated tickets in Kayako. They should verify:

- Can they find specific tickets by searching the `legacy_puzzel_id` custom field?
- Is the conversation history complete and readable?
- Are custom fields populated correctly?
- Do organisation profiles look right?
- Can they open and download attachments?

### Rollback Planning

Before the production load:

- Document the exact Kayako state (empty or pre-existing data)
- If Kayako was empty, rollback = delete all imported records via API (use the `migration_batch_id` to identify migrated records)
- If Kayako had existing data, tag all migrated records with a `migration_batch_id` custom field so you can selectively delete
- <cite index="60-1">You can request a data dump or backup for your Kayako instance, which contains all instance data in a MySQL dump file format.</cite> Take a backup before migration.

Write down rollback criteria before production: who can trigger a rollback, which batches are safe to rerun, and what constitutes a failed cutover (e.g., >5% record count mismatch, any data corruption in sampled tickets).

## Post-Migration Tasks

Once the delta sync is complete and data is validated:

### Update DNS and Email Forwarding

Redirect your support email addresses from Puzzel to Kayako. Keep the Puzzel instance read-only until the final delta sync passes validation. Verify email delivery by sending test messages to each support address and confirming they arrive in Kayako.

### Rebuild Automations

Puzzel Smart Rules, Inbound Rules, and Event Rules do not transfer. Manually rebuild in Kayako using triggers and monitors. Document each Puzzel rule and its Kayako equivalent before cutover:

| Puzzel Rule | Kayako Equivalent | Implementation Notes |
|---|---|---|
| Inbound Rule (route by subject) | Trigger (on case create) | Match subject with regex, assign team |
| Event Rule (escalate on SLA breach) | Monitor (time-based) | Configure SLA policy + escalation trigger |
| Smart Rule (auto-categorise) | Trigger (on case create/update) | Set tags or custom fields based on conditions |
| Response Mapping (webhook enrichment) | External webhook + API callback | Build external service; no native equivalent |

### Rebuild SLA Policies

Recreate SLA targets in Kayako. Map Puzzel's per-team and per-category SLA policies to Kayako's SLA policy structure. Kayako SLAs are configurable per priority and type — match your Puzzel configuration as closely as possible.

### Activate Automations

Turn on Kayako triggers, SLAs, and routing rules. Keep these **disabled during the API load** to prevent sending notifications to customers for migrated tickets. Enable them only after the production load is complete and validated.

### Agent Training

Kayako's UI differs substantially from Puzzel's agent dashboard. Plan for:

- A walkthrough of the Kayako conversation view vs Puzzel ticket view
- Training on Kayako's trigger system (replaces Puzzel's rules engine)
- How to search for legacy Puzzel ticket IDs using the `legacy_puzzel_id` custom field
- How to interpret `original_created_at` vs Kayako's system `created_at`
- Updated internal documentation for workflows, escalation paths, and SLA handling

### Monitor for Data Inconsistencies

For 1–2 weeks after cutover:

- Monitor support queue for agents reporting missing data
- Run daily count comparisons between the ID mapping table and Kayako
- Check for orphaned records (cases without requesters, users without organisations)
- Verify no duplicate tickets were created during the delta sync window

## Best Practices

1. **Backup everything.** Export a full snapshot from Puzzel and take a Kayako backup (MySQL dump) before production loading.
2. **Never test in production.** Execute at least two full dry runs against a Kayako sandbox before the production load. Compare results between runs for consistency.
3. **Migrate in dependency order.** Always: organisations → users → cases → posts → notes → attachments. Violating this order creates orphaned records.
4. **Maintain a durable ID mapping table.** Use SQLite or PostgreSQL, not an in-memory dict. Store `puzzel_id`, `kayako_id`, `object_type`, `migration_batch_id`, and `migration_timestamp` for every record.
5. **Freeze the Puzzel schema.** Impose a change freeze on Puzzel custom fields and categories at least two weeks before migration. Any structural changes after the mapping is finalised will break the migration.
6. **Validate incrementally.** Don't wait until the full load is done. Validate after each object type — check org counts before loading users, check user counts before loading cases.
7. **Log everything.** Every API call, every response code, every transformation decision. Write to a file, not just stdout. You'll need this for debugging and post-migration auditing.
8. **Handle rate limits gracefully.** Implement exponential backoff with jitter. Never retry immediately. Respect the `Retry-After` header value.
9. **Communicate with agents.** Give your support team a migration timeline, a testing window, and a clear cutover date. Unexpected UI changes without context cause support quality drops.
10. **Don't force-fit data.** If the only honest mapping for a Puzzel object is a note, tag, or archive link in Kayako, do that rather than creating a broken abstraction that misleads agents.

## Sample Data Mapping Table

| Puzzel Field | Kayako Field | Type | Transformation |
|---|---|---|---|
| Ticket ID | `legacy_puzzel_id` (custom field) | Text | Preserve for reference and auditability |
| Subject | Subject | Text | Direct copy |
| Status | Status | Enum | New→New, Open→Open, Pending→Pending, On Hold→Pending (+tag), Resolved→Completed, Closed→Closed |
| Priority | Priority | Enum | Low→Low, Medium→Normal, High→High, Critical→Urgent |
| Team | Team | Reference | Map by name or create lookup table |
| Assigned Agent | Assigned Agent | Reference | Map by email address |
| Customer Email | Requester (user) | Reference | Lookup or create user by email; handle 409 Conflict |
| Organisation | Organization | Reference | Lookup or create by name; load before users |
| Category: Query Type | Custom Field or Tag | Dropdown/Tag | Flatten or map to custom dropdown (see options above) |
| Form Field: Contract ID | Custom Field (conversation) | Text | Create matching custom field in Kayako |
| Tags | Tags | Array | Direct copy |
| Message (HTML body) | Post contents | HTML | Sanitize HTML, replace CID image references with Kayako URLs |
| Note (private) | Note | Text | Direct copy |
| Note (public) | Post | Text | Convert to case post; prefix with `[Migrated Note]` |
| Created Date | `original_created_at` (custom field) | DateTime | Store in custom field (Kayako may not accept `created_at` override) |
| Attachment | Attachment | Binary | Download from Puzzel, upload to Kayako via multipart POST |

## Making the Right Call

A Puzzel Case Management to Kayako migration is a structural data translation — Puzzel's contact-centre-oriented categories, forms, and team-queue mappings don't have direct equivalents in Kayako's conversation-centric model. The migration is achievable with the approach outlined here, but the mapping decisions (especially multi-tier categories, timestamp handling, and public note conversion) are where the real engineering work lives.

The critical path items to resolve before writing migration code:

1. Test whether your Kayako tenant accepts `created_at` overrides
2. Decide on category flattening strategy (tags vs. custom dropdowns vs. leaf-only)
3. Benchmark your Kayako API rate limits
4. Confirm Puzzel API endpoint paths from your tenant's Swagger documentation
5. Map all Puzzel statuses including On Hold

If your team has the engineering bandwidth and API experience, this guide gives you a complete path. If you'd rather keep your engineers focused on product work, ClonePartner specialises in helpdesk migrations — including complex source platforms like Puzzel Case Management where API documentation isn't publicly indexed and extraction requires tenant-level access. [Talk to us about your migration](https://clonepartner.com/blog/blog/how-we-run-migrations-at-clonepartner/).

For validation best practices, see our [post-migration QA checklist](https://clonepartner.com/blog/blog/help-desk-data-migration-qa-checklist/).

> Need help migrating from Puzzel Case Management to Kayako? Our team handles the extraction, mapping, and validation — so your support operations don't skip a beat.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Can I migrate full ticket history from Puzzel Case Management to Kayako?

Yes, but not via CSV. Puzzel's CSV export captures ticket metadata but strips full message threads, notes, and attachments. You must use Puzzel's REST API (accessed via your tenant's Swagger UI) to extract complete conversation history, then load into Kayako via the /api/v1/cases.json endpoint.

### How long does a Puzzel Case Management to Kayako migration take?

A typical mid-size migration (10,000–50,000 tickets) takes 2–4 weeks total: 3–4 days for planning and mapping, 3–5 days for extraction, 3–5 days for transformation and test loads, 2–3 days for production loading and delta sync, and 2–3 days for validation and cutover.

### How do I map Puzzel categories to Kayako?

Puzzel uses multi-tier categories (e.g., Query Type > Billing > Refund). Kayako has no hierarchical category system. You can flatten categories into tags, map them to custom dropdown fields, or concatenate into text fields. Custom dropdown fields preserve the most structure but require pre-creation in Kayako.

### Does Kayako have API rate limits that affect migration?

Yes. Kayako API rate limits are defined per minute. If exceeded, the API returns HTTP 429 with a Retry-After header. Exact limits vary by plan and aren't publicly documented. Kayako Classic (self-hosted) has no rate limits. Implement exponential backoff in your migration scripts.

### Can Puzzel Case Management automations be migrated to Kayako automatically?

No. Puzzel Smart Rules, Inbound Rules, Event Rules, and SLA policies cannot be migrated programmatically. They must be manually rebuilt in Kayako using triggers, monitors, and SLA policies. Document every Puzzel rule before starting the migration.
