Unthread to Puzzel Case Management Migration: Technical Guide
Technical guide to migrating from Unthread to Puzzel Case Management. Covers API extraction, Slack thread mapping, data model translation, and step-by-step scripts.
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
TL;DR: Unthread → Puzzel Case Management Migration
An Unthread to Puzzel Case Management migration is a data-model translation from a Slack-native conversational helpdesk into a contact-centre-oriented ticketing system. There is no native migration path. Unthread has no bulk CSV export for ticket data — you must extract everything through the Unthread REST API (https://api.unthread.io/api/), paginated at 100 records per page with cursor-based iteration. On the Puzzel side, tickets are created one at a time through API ticket channels using Basic Token or OAuth authentication. Unthread's Conversations, Customers, Accounts, Ticket Types, and Tags do not map 1:1 to Puzzel's Tickets, Organisations, Teams, Categories, Forms, and Form Fields — each requires deliberate structural decisions. Automations, SLA rules, and AI workflows cannot be migrated programmatically and must be rebuilt manually. A typical mid-size migration (5,000–25,000 conversations) takes 2–4 weeks; in our experience, a 10,000-conversation migration with full thread history requires approximately 8–12 hours of API runtime plus 1–2 weeks of mapping, testing, and validation work.
What Is an Unthread to Puzzel Case Management Migration?
An Unthread to Puzzel Case Management migration moves conversations, messages, customers, accounts, tags, ticket types with custom fields, knowledge base articles, and file attachments from Unthread into Puzzel Case Management — while preserving conversation history, timestamps, and relational integrity.
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. Each e-mail 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. It handles incoming requests across email, SMS, and API integrations, and is part of the broader Puzzel CX ecosystem.
Unthread is a Slack-native AI helpdesk that solves tickets right from Slack and Teams with end-to-end workflow automations that learn from your team. Its data model is conversation-centric: everything hangs off Conversations linked to Customers, Accounts, Tags, and Ticket Types with custom fields.
These are fundamentally different architectures. Unthread is built around Slack users, channels, and threaded messages. Puzzel's model is built on Tickets, Contacts, Organisations, Teams, Categories, and Forms. The migration requires flattening hierarchical Slack threads into chronological ticket interactions while translating Slack's proprietary markdown, user mentions (<@U12345>), and channel references into standard HTML. This is a schema translation project, not a copy-paste job.
Why Teams Migrate from Unthread to Puzzel Case Management
- Contact centre consolidation: Puzzel Case Management integrates directly with Puzzel Contact Centre, unifying voice and written channels under one platform. Teams consolidating onto a full CCaaS stack need this integration to avoid operating parallel systems.
- Email-first workflows: Puzzel efficiently handles incoming support requests from email, SMS, and API integrations by categorising, prioritising, and routing them to agents based on their most relevant skills. Teams shifting away from Slack-native support toward traditional email ticketing with skill-based routing benefit from Puzzel's agent assignment engine.
- Regulated industry requirements: Puzzel offers European-hosted infrastructure (data centres in the EU/EEA) and contact-centre-grade compliance capabilities including GDPR data retention controls and audit logging. Slack-native tools may not provide equivalent controls for industries with strict data residency or retention mandates (e.g., financial services, healthcare, government).
- Outgrowing Slack-first support: As B2B support operations scale beyond ~10 agents into formal contact centre operations with unified workforce management, strict SLA enforcement across multiple channels (especially voice), and structured reporting, the move from conversational ticketing to structured case management becomes necessary.
A word of caution: if the real business driver is marketing automation, lead management, or opportunity pipelines, Puzzel is not the right target. It is a case management system, not a CRM. Those records should stay in your CRM and be linked into Puzzel through external reference IDs, custom attributes, or form fields.
Migration Approaches: All Viable Methods
There is no push-button migration path between Unthread and Puzzel. No third-party migration tool (e.g., Import2, Help Desk Migration) supports this specific pairing at the time of writing. Every approach requires custom work.
API-to-API Migration (Custom Scripts)
How it works: Extract all data from Unthread's REST API, transform it into Puzzel's schema, and load it via Puzzel's API ticket channels.
- Create an Unthread service account and generate an API key (requires Unthread plan with API access — verify with Unthread support)
- Use
POST /conversations/list,POST /customers/list,POST /accounts/list,POST /tags/list,POST /ticket-types/listto extract all entities - For each conversation, call
POST /conversations/:id/messages/listandGET /conversations/:id/files/:fileId/fullto get full thread history and attachments - Transform data into Puzzel's schema (Teams, Categories, Forms, Form Fields, Organisations)
- Configure API ticket channels in Puzzel (Settings > Ticket Channels > API)
- Load tickets via Puzzel's ticket creation API with appropriate authentication
- Run delta sync for conversations created during the migration window
When to use: Any migration over 1,000 conversations, or when you need full message history and attachment preservation.
Pros: Full control over data mapping and transformation. Preserves complete conversation threads and metadata. Scriptable, repeatable, auditable.
Cons: Requires engineering time (Python/Node.js). Must handle pagination, error retries, and rate limiting. Puzzel's API documentation is accessible via Swagger UI from within the admin interface (Help → API Documentation), requiring initial setup to review available endpoints and schemas.
Scalability: Works from small to enterprise. Pagination at 100 records per page means 50,000 conversations require at minimum 500 list calls plus individual message-thread calls per conversation.
CSV/JSON Intermediate Export
How it works: Extract data from Unthread's API, write to CSV or JSON files, then manually or semi-automatically import into Puzzel.
Unthread does not offer a native CSV export for ticket data. Any CSV-based approach still requires API extraction on the Unthread side. Puzzel's public CSV import documentation covers customers and address books, not historical ticket ingestion.
When to use: Small datasets (under 1,000 conversations) where you need a human-reviewable intermediate step, or contact-only migrations where you are deliberately leaving history behind.
Pros: Human-reviewable intermediate files. Easier to spot data quality issues before loading. Allows non-engineer stakeholders to review mappings.
Cons: Still requires API extraction from Unthread. Manual import into Puzzel is time-consuming for anything over a few hundred tickets. Message threading and attachments are harder to preserve in flat CSV.
Scalability: Small datasets only. Anything over 2,000 conversations and this approach becomes impractical.
Middleware/Integration Platforms (Zapier, Make, Pipedream)
How it works: Use a middleware platform to connect Unthread and Puzzel, transferring records via webhooks or polling.
Unthread supports webhook events such as conversation_created, conversation_updated, and message_created. Pipedream has a native Unthread integration. You subscribe to these events, transform the payload in your middleware, and call Puzzel's API to create or update the ticket. Note that Unthread expects URL verification within 3 seconds and normal webhook responses within 30 seconds.
When to use: Ongoing sync during a phased transition where you want both platforms running in parallel, or for low-volume drip sync of new conversations after the main migration.
Pros: Low-code setup for simple scenarios. Good for ongoing sync during transition.
Cons: Not designed for historical bulk migration. Execution limits, timeout constraints, and per-task pricing add up. Complex transformations are painful in low-code environments. Attachment and idempotency handling gets messy fast.
Scalability: Poor for bulk migration. Reasonable for ongoing drip sync of new conversations.
Managed Migration Service
How it works: A specialist migration team handles extraction, transformation, loading, validation, and cutover.
When to use: When your team lacks bandwidth, the dataset is large or complex, or you cannot afford migration-related downtime or data loss.
Pros: Fastest path to completion. Risk mitigation through experienced operators. Handles edge cases (attachments, threading, deduplication). Validation and rollback planning included.
Cons: External cost (typically $3,000–$15,000+ depending on volume and complexity). Requires giving API access to a third party.
Scalability: Designed for it.
Migration Approach Comparison
| Approach | Best For | Preserves Threads | Handles Attachments | Bulk Scale | Complexity |
|---|---|---|---|---|---|
| API-to-API (Custom) | Full-fidelity, mid-to-large | Yes | Yes | Yes | High |
| CSV/JSON Intermediate | Small, auditable, contacts only | Partial | Manual | No | Medium |
| Middleware (Zapier/Make) | Ongoing sync, tiny datasets | Partial | Limited | No | Low–Medium |
| Managed Service | Any scale, tight timelines | Yes | Yes | Yes | Low (for you) |
Recommendations by Scenario
- Small team (<1,000 conversations), dev available: API-to-API with a simple Python script. Budget 1–2 weeks.
- Mid-size (1,000–25,000 conversations): API-to-API with a dedicated sprint, or use a managed service. Budget 2–4 weeks.
- Enterprise (25,000+ conversations): Managed migration service. API pagination and transformation at this scale demands dedicated tooling, queuing, and monitoring.
- Ongoing sync during transition: Middleware for new conversations plus API-to-API for historical backfill.
- Archive-and-restart: CSV for contacts only, and leave historical tickets behind in a read-only Unthread archive.
When to Use a Managed Migration Service
Building an ETL pipeline for a helpdesk migration often looks like a simple weekend project on paper. In practice, it consumes weeks of engineering time. The most common failure modes we see across our migrations:
- Broken message threading: Conversations import as flat tickets, losing reply chains and internal/external distinctions
- Orphaned attachments: Slack-backed file URLs expire (typically within hours) or aren't downloaded before cutover, resulting in HTTP 403 Forbidden errors
- Duplicate records: Re-runs without idempotency create duplicate tickets in Puzzel. Without a
source_id → target_idmapping table, there's no way to detect or prevent this. - Custom field data loss: Ticket Type fields in Unthread don't map cleanly to Puzzel's Form Fields without explicit transformation — particularly
multi-user-selectfields and conditional fields - Slack markdown in production: User mentions (
<@U12345>) and Block Kit formatting render as raw text because nobody built the translation layer - Hidden engineering costs: What starts as a "2-day script" turns into 3 weeks of edge case debugging. In our experience, the extraction script typically accounts for ~20% of total effort; the remaining 80% is transformation logic, error handling, and validation.
There are also platform-specific traps. Puzzel API access is feature-gated — some workflow features such as forms, event rules, outbound integrations, and response mappings may need support activation before you can use them. Puzzel treats Closed differently from Resolved: closed tickets cannot be reopened. And retention settings can block deletion of recent tickets and customer records, which directly affects rollback planning.
ClonePartner handles Unthread and Puzzel migrations with a tested extraction-transformation-loading pipeline. We map Ticket Types to Categories/Forms, preserve full conversation threads, handle file downloads before URL expiry, and run automated validation against source counts. If your team's engineering bandwidth is better spent on product work, talk to us.
Pre-Migration Planning
Data Audit Checklist
Before writing a single line of migration code, inventory what you have in Unthread:
| Entity | Where to Find | API Endpoint | Typical Volume |
|---|---|---|---|
| Conversations (tickets) | Inbox / API | POST /conversations/list |
Primary dataset |
| Messages (thread replies) | Per conversation | POST /conversations/:id/messages/list |
Avg. 3–8 per conversation |
| Customers (Slack channels) | Customer list | POST /customers/list |
1 per channel |
| Accounts | Account list | POST /accounts/list |
1 per company |
| Tags | Settings / API | POST /tags/list |
Typically 10–100 |
| Ticket Types + Custom Fields | Settings / API | POST /ticket-types/list |
Typically 2–20 |
| Knowledge Base Articles | KB / API | POST /knowledge-base/articles/list |
Varies |
| Users (agents) | Synced from Slack | POST /users/list |
Typically 5–50 |
| File Attachments | Per conversation | GET /conversations/:id/files/:fileId/full |
Varies; check total size |
Source Completeness Traps
Audit source completeness before assuming you have everything. Unthread's email connector only creates tickets from new emails received after the connector is set up — it does not backfill older emails. Slack channel backfill is optional and capped at six months on connected channels. If upstream CRM data drives support context, audit those systems separately to understand what actually exists in Unthread versus what lives elsewhere.
Identify What Not to Migrate
- Test/sandbox conversations: Filter by tag or status
- Spam or auto-closed tickets: Conversations with no replies and immediate closure
- Duplicate accounts: Unthread may have both Customer and Account records for the same entity
- Archived Slack channels: Conversations linked to archived channels may not need migration
- Legacy closed tickets: Do you need three years of history, or just the last 12 months? Filtering reduces API load and speeds up cutover. In our experience, most teams only need 6–18 months of active history.
Migration Strategy
- Big bang: Migrate everything in a single cutover window. Works for small datasets (<2,000 conversations). Risk: if something goes wrong, recovery is harder.
- Phased: Migrate by Customer or Account grouping. Validate each phase before proceeding. Preferred for mid-size migrations (2,000–25,000 conversations).
- Incremental with delta sync: Migrate historical data first, then sync new conversations created during the migration window. Required for zero-downtime migrations. See our zero-downtime approach for details.
The recommended approach for most teams is a phased cutover with delta sync: extract and load historical data up to a freeze date, validate, run a delta migration over the cutover weekend, then route new incoming messages directly to Puzzel.
Data Model and Object Mapping
This is where migrations succeed or fail. The two platforms use fundamentally different schemas.
Core Object Mapping
| Unthread Object | Puzzel Case Management Equivalent | Notes |
|---|---|---|
| Conversation | Ticket | 1:1 mapping. Status values need translation. |
| Message | Ticket reply / Internal note | Each message becomes a reply or internal note. Flag internal vs public based on metadata.eventType. |
| Customer (Channel) | Organisation | Unthread Customers are Slack channels; map to Puzzel Organisations. |
| Account | Organisation | Accounts may also map to Organisations. Deduplicate against Customers. |
| Tag | Tag | Direct mapping. Create tags in Puzzel first. |
| Ticket Type | Form | Each Ticket Type becomes a Puzzel Form. |
| Ticket Type Field | Form Field | Field types need translation (see below). |
| User (Agent) | User/Agent | Map by email address. Ensure agents exist in Puzzel before ticket import. |
| Knowledge Base Article | N/A (external) | Puzzel CM doesn't have a built-in KB. Export separately to an external tool. |
| File Attachment | Ticket Attachment | Download from Unthread during extraction, re-upload to Puzzel after ticket creation. |
Status Mapping
| Unthread Status | Puzzel Equivalent | Notes |
|---|---|---|
open |
New / Open | Default for unassigned conversations. |
in_progress |
In Progress / Assigned | Use Assigned when assignedToUserId is populated. |
on_hold |
Pending | |
closed |
Resolved | Map to Resolved, not Closed. Closed tickets in Puzzel cannot be reopened. Use Closed only if your service model requires immutable closure (e.g., regulatory finality). |
Priority Mapping
| Unthread Priority | Puzzel Equivalent |
|---|---|
| 3 | Critical |
| 5 | High |
| 7 | Medium |
| 9 | Low |
Confirm this mapping with your operations team before loading. If Unthread priorities were customized beyond the defaults, adjust accordingly.
Field Type Translation
| Unthread Ticket Type Field | Puzzel Form Field Equivalent | Notes |
|---|---|---|
short-answer |
Text field | Direct mapping. |
long-answer |
Textarea / Multi-line text | Direct mapping. |
single-select |
Dropdown | Ensure option values match exactly. |
multi-select |
Multi-select dropdown | Verify Puzzel supports multi-select on the target Form. |
checkbox |
Checkbox | Direct mapping. |
user-select |
User dropdown (if supported) | Test in Puzzel sandbox; may require workaround. |
multi-user-select |
No direct equivalent | Serialize to comma-separated email list in a text field, or create multiple single-user fields. |
Conditional field visibility is lost. Unthread's Ticket Type Fields support conditional visibility — a field can be shown or hidden based on another field's value using SimpleCondition logic (e.g., "Show field B only when field A = 'Billing'"). Puzzel's Form Fields do not have documented conditional logic. You'll need to flatten conditional fields into always-visible ones and document the original conditions for the receiving team. Consider adding a "(Conditional: only relevant when X = Y)" note in the field description.
Handling Slack Markdown
Unthread messages contain Slack mrkdwn formatting. Key translation requirements:
| Slack Format | Raw Text | Target HTML |
|---|---|---|
| User mention | <@U012AB3CDE> |
@Jane Doe (resolve via API) |
| Channel reference | <#C012AB3CDE> |
#channel-name (resolve via API) |
| Bold | *bold* |
<strong>bold</strong> |
| Italic | _italic_ |
<em>italic</em> |
| Strikethrough | ~struck~ |
<del>struck</del> |
| Code inline | `code` |
<code>code</code> |
| Code block | ```code``` |
<pre><code>code</code></pre> |
| Link | <https://example.com|text> |
<a href="https://example.com">text</a> |
| Emoji | :thumbsup: |
👍 (Unicode) or strip |
Your transformation layer must:
- Query the Unthread Users API (
POST /users/list) to build auserId → displayNamelookup table - Replace all
<@U...>mentions with resolved names - Convert Slack mrkdwn syntax to standard HTML using the table above
- Handle Slack Block Kit elements: extract text content, discard interactive components (buttons, menus)
Slack reactions (emoji reactions on messages) cannot be migrated into Puzzel as native metadata. If preserving reaction data matters for audit purposes, serialize reactions as a JSON blob in a Puzzel Form Field or append as an internal note (e.g., "Reactions: 👍 x3, ✅ x1").
The Customer vs Account Problem
Unthread has two entity types that can represent a company: Customers (tied to Slack channels, with fields like slackChannelId and name) and Accounts (a higher-level grouping with emailsAndDomains, slackChannelIds, and customFields). Puzzel has Organisations with custom attributes.
Before migrating, decide:
- Are your Accounts and Customers 1:1? If so, merge them into a single Organisation. Use the Account as the primary record (it has richer metadata).
- Do some Customers belong to the same Account? If so, the Account becomes the Organisation, and Customer-level detail (Slack channel name, channel-specific context) becomes metadata on the Organisation or on individual tickets.
- Are there orphan Conversations with no Customer or Account? These need a fallback Organisation (e.g., "Unassigned / Unknown") or get imported as unassigned tickets.
Deduplicate contacts based on email domain (emailsAndDomains on Accounts) and Slack channel ID before creating Puzzel Organisations. Two different Customers might reference the same Slack channel, or an Account might aggregate multiple Customers. Build a deduplication report and flag ambiguous cases for manual review before loading.
Migration Architecture
Data Flow: Extract → Transform → Load
Unthread API Transform Layer Puzzel API
┌──────────────┐ JSON ┌──────────────────┐ JSON ┌──────────────┐
│ /conversations│───────────►│ Schema mapping │────────►│ Ticket Channel│
│ /messages │ │ Status mapping │ │ (API) │
│ /customers │ │ Field translation│ │ │
│ /accounts │ │ Slack mrkdwn→HTML│ │ │
│ /tags │ │ Deduplication │ │ │
│ /ticket-types │ │ File download │ │ │
│ /files │ │ ID mapping table │ │ │
│ /users │ └──────────────────┘ └──────────────┘
└──────────────┘
│ │ │
└── Raw JSON backup ─────────┘ │
(local/S3) └── Mapping DB ──────────────┘
(source_id → target_id)
Unthread API Extraction Details
The Unthread API follows RESTful conventions when possible, with most operations performed via GET, POST, PATCH, and DELETE requests. Request and response bodies are encoded as JSON.
Authentication: You must send an HTTP header with the key "X-Api-Key" and the value set to the service account key. You can set up your own Service Account to get an API key with specific permissions. Service Accounts are given permissions just like users, and they can be scoped to a Project, or be tenant-wide.
API access requirements: Verify that your Unthread plan includes API access. Not all plans may expose the full API surface. Contact Unthread support to confirm before building your extraction pipeline.
Pagination: All list endpoints return a maximum of 100 records per page with cursor-based pagination. The response body includes:
{
"data": [...],
"cursors": {
"hasNext": true,
"next": "cursor_string_here"
}
}Iterate through cursors.next until cursors.hasNext is false.
Rate limits: Unthread does not publicly document API rate limits. In our experience, throttling to 2–3 requests per second avoids hitting undocumented limits. We have observed HTTP 429 responses at sustained rates above 5 requests per second on some instances. Implement exponential backoff with jitter starting at 1 second.
If your Unthread instance uses multiple Projects, use a tenant-wide API key to extract across all Projects. You'll need to decide whether each Project becomes a separate Team in Puzzel or gets merged into a single Team.
Puzzel API Import Details
Go to Settings > Ticket Channels > API. Select an authentication level — Global (Access to all tickets) or select an Organisation. Select your Token Type (Basic Token OR OAuth Token).
Puzzel's API security is based on OpenID Connect, an identity layer on top of the OAuth 2.0 protocol.
Accessing API documentation: Puzzel's Case Management API documentation is accessible via Swagger UI from within the admin interface (Help → API Documentation). The Swagger UI exposes all available endpoints, request/response schemas, and required fields. You cannot access this documentation without an authenticated Puzzel account. Key endpoints to locate in Swagger:
- Ticket creation (POST)
- Ticket reply/interaction creation (POST)
- Attachment upload (POST, multipart/form-data)
- Organisation creation (POST)
- Tag management (GET/POST)
Rate limits: Puzzel does not publicly document API rate limits for Case Management. Contact your Puzzel account manager to confirm rate limits for your instance before running bulk imports. In our experience, starting with 1 request per second and gradually increasing while monitoring for 429 responses is the safest approach.
Common API error responses to handle:
| HTTP Status | Meaning | Action |
|---|---|---|
| 400 | Malformed request / missing required field | Log payload, fix schema |
| 401 | Authentication expired | Refresh OAuth token |
| 403 | Insufficient permissions or feature not activated | Contact Puzzel support |
| 404 | Referenced entity (e.g., Organisation, Form) doesn't exist | Check dependency order |
| 409 | Conflict / duplicate | Check mapping table |
| 429 | Rate limited | Backoff using Retry-After header |
| 500/502/503 | Server error | Retry with exponential backoff, max 3 attempts |
The 'Outbound Integrations', 'Event Rules', and 'Response Mappings' features require activation on your Puzzel Case Management instance before use. Request activation before migration.
You must create the ticket first, retrieve the generated ticket ID, and then POST replies and attachments against that ID. There is no documented bulk import endpoint — tickets are created one at a time.
If you're managing a large number of API channels, Puzzel offers a scalable solution that simplifies the process: creating and maintaining templates that your API channels can inherit their settings from. Use API channel templates for large-scale imports with consistent configuration.
Handling Large Datasets
For datasets exceeding 50,000 conversations, synchronous API calls will take too long. Here's the math:
| Dataset Size | Avg. Messages/Conv | Total API Calls | Est. Runtime @ 60 req/min | Est. Runtime @ 180 req/min |
|---|---|---|---|---|
| 10,000 | 5 | ~60,000 | ~17 hours | ~5.5 hours |
| 50,000 | 5 | ~300,000 | ~83 hours | ~28 hours |
| 100,000 | 5 | ~600,000 | ~167 hours | ~56 hours |
These estimates include ticket creation + reply creation + attachment uploads. Actual runtime depends on payload size, network latency, and rate limits.
Implement a queuing system (Redis Queue, Celery, or RabbitMQ) to process extraction and loading asynchronously. Use worker pools with configurable concurrency to stay within rate limits. Plan your cutover window accordingly — a 100K-conversation migration may require a multi-day cutover or a parallel-run strategy.
Step-by-Step Migration Process
Step 1: Extract Data from Unthread
import requests
import time
import json
import os
API_KEY = "your-unthread-api-key"
BASE_URL = "https://api.unthread.io/api"
HEADERS = {"X-Api-Key": API_KEY, "Content-Type": "application/json"}
def extract_all(endpoint, select_fields=None):
"""Extract all records from a paginated Unthread list endpoint."""
all_records = []
cursor = None
page = 0
while True:
payload = {"limit": 100}
if select_fields:
payload["select"] = select_fields
if cursor:
payload["cursor"] = cursor
resp = requests.post(f"{BASE_URL}/{endpoint}/list",
headers=HEADERS, json=payload)
if resp.status_code == 429:
retry_after = int(resp.headers.get("Retry-After", 5))
print(f"Rate limited on {endpoint}, waiting {retry_after}s")
time.sleep(retry_after)
continue
resp.raise_for_status()
data = resp.json()
all_records.extend(data["data"])
page += 1
print(f"{endpoint}: page {page}, total records: {len(all_records)}")
if not data["cursors"]["hasNext"]:
break
cursor = data["cursors"]["next"]
time.sleep(0.4) # Throttle: ~2.5 req/sec
return all_records
# Extract core entities
conversations = extract_all("conversations")
customers = extract_all("customers")
accounts = extract_all("accounts")
tags = extract_all("tags")
ticket_types = extract_all("ticket-types")
users = extract_all("users")
# Save raw backup
os.makedirs("backup", exist_ok=True)
for name, data in [("conversations", conversations), ("customers", customers),
("accounts", accounts), ("tags", tags),
("ticket_types", ticket_types), ("users", users)]:
with open(f"backup/{name}.json", "w") as f:
json.dump(data, f, indent=2)
print(f"Backed up {len(data)} {name}")Store this raw JSON locally or in a database (PostgreSQL or SQLite) to prevent re-querying the API if your transformation script fails. Never run a migration without a local backup of the source data.
Step 2: Extract Messages and Attachments per Conversation
os.makedirs("attachments", exist_ok=True)
def extract_messages(conversation_id):
"""Get all messages for a conversation."""
return extract_all(f"conversations/{conversation_id}/messages")
def download_attachment(conversation_id, file_id, filename):
"""Download a file attachment immediately to prevent URL expiry."""
resp = requests.get(
f"{BASE_URL}/conversations/{conversation_id}/files/{file_id}/full",
headers=HEADERS
)
if resp.status_code == 403:
print(f"WARNING: File {filename} (conv {conversation_id}) - URL expired (403)")
return False
resp.raise_for_status()
filepath = f"attachments/{conversation_id}_{file_id}_{filename}"
with open(filepath, "wb") as f:
f.write(resp.content)
return True
attachment_failures = []
for i, conv in enumerate(conversations):
conv["_messages"] = extract_messages(conv["id"])
for file_info in (conv.get("files") or []):
success = download_attachment(conv["id"], file_info["id"], file_info["name"])
if not success:
attachment_failures.append({
"conversation_id": conv["id"],
"file_id": file_info["id"],
"filename": file_info["name"]
})
if (i + 1) % 100 == 0:
print(f"Processed {i+1}/{len(conversations)} conversations")
time.sleep(0.2)
# Save messages backup
with open("backup/conversations_with_messages.json", "w") as f:
json.dump(conversations, f, indent=2)
if attachment_failures:
print(f"WARNING: {len(attachment_failures)} attachments failed to download")
with open("backup/attachment_failures.json", "w") as f:
json.dump(attachment_failures, f, indent=2)Download attachments during extraction, not later. Slack-backed file URLs can expire within hours. If you extract the URL but wait to download, you'll get HTTP 403 Forbidden responses. Write files to disk or cloud storage (S3, GCS) before any transformation begins. Log all failures for manual follow-up.
Step 3: Transform Data
The transformation layer handles:
- Status mapping: Translate Unthread statuses to Puzzel equivalents (map
closedtoResolved, notClosed) - Priority mapping: Unthread uses numeric priority (3, 5, 7, 9); map to Puzzel's priority scheme
- Customer/Account → Organisation: Merge and deduplicate based on email domain and Slack channel ID
- Ticket Type → Form: Create Forms in Puzzel with mapped Form Fields
- Tags: Create tag list in Puzzel before ticket import
- Slack mrkdwn → HTML: Resolve user mentions, convert formatting (see Slack markdown table above)
- Timestamps: Ensure ISO 8601 format with timezone (Unthread uses UTC)
- Message ordering: Sort by
createdAttimestamp to ensure chronological ordering - Internal vs external classification: Use
message.metadata.eventTypeto flag internal notes
Step 4: Pre-Create Reference Data in Puzzel
Before importing tickets, create these in strict dependency order:
- Teams — Map from Unthread's Projects or user groups. Each Team in Puzzel controls which Forms and Categories are visible.
- Categories — Map from Unthread Tags or Ticket Types (decide which source drives your category tree)
- Forms and Form Fields — Map from Unthread Ticket Types and their fields. Ensure each Form is visible to the assigned Team. Test form field validation rules in a sandbox first.
- Organisations — Map from Unthread Customers/Accounts (after deduplication)
- Users/Agents — Ensure all agents exist in Puzzel (match by email). Agents who left the company should map to a generic "Former Agent" user or be skipped.
- Tags — Pre-create all tags that exist in Unthread
Record the Puzzel-generated IDs for each entity. Store them in your mapping database for use during ticket loading.
Step 5: Load Tickets into Puzzel
import requests
import time
import json
import sqlite3
# Initialize mapping database
conn = sqlite3.connect("migration_mapping.db")
conn.execute("""
CREATE TABLE IF NOT EXISTS ticket_mapping (
source_id TEXT PRIMARY KEY,
target_id TEXT,
status TEXT,
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
def get_existing_mapping(source_id):
"""Check if a ticket was already migrated (idempotency)."""
row = conn.execute(
"SELECT target_id FROM ticket_mapping WHERE source_id = ? AND status = 'success'",
(source_id,)
).fetchone()
return row[0] if row else None
def create_puzzel_ticket(ticket_data, token, source_id):
"""Create a ticket in Puzzel via API ticket channel with idempotency."""
existing = get_existing_mapping(source_id)
if existing:
print(f"Skipping {source_id} - already migrated as {existing}")
return {"id": existing}
# Replace with actual Puzzel API endpoint from your Swagger docs
url = "https://your-instance.puzzel.com/ticketapi/tickets"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
response = requests.post(url, headers=headers, json=ticket_data)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 5))
time.sleep(retry_after)
return create_puzzel_ticket(ticket_data, token, source_id)
if response.status_code >= 400:
error_msg = response.text[:500]
conn.execute(
"INSERT OR REPLACE INTO ticket_mapping (source_id, status, error) VALUES (?, ?, ?)",
(source_id, "failed", error_msg)
)
conn.commit()
print(f"FAILED: {source_id} - {response.status_code}: {error_msg}")
return None
result = response.json()
target_id = result.get("id") or result.get("ticketId")
conn.execute(
"INSERT OR REPLACE INTO ticket_mapping (source_id, target_id, status) VALUES (?, ?, ?)",
(source_id, target_id, "success")
)
conn.commit()
return result
def add_interaction(ticket_id, message_body, author_email, is_internal, token):
"""Add a reply or internal note to an existing Puzzel ticket."""
# Replace with actual Puzzel API endpoint from your Swagger docs
url = f"https://your-instance.puzzel.com/ticketapi/tickets/{ticket_id}/replies"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
payload = {
"channel": "API",
"direction": "Inbound" if not is_internal else "Internal",
"body": message_body,
"fromAddress": author_email,
"isPrivate": is_internal
}
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 5))
time.sleep(retry_after)
return add_interaction(ticket_id, message_body, author_email, is_internal, token)
if response.status_code >= 400:
print(f"FAILED reply on ticket {ticket_id}: {response.status_code}: {response.text[:200]}")
return None
return response.json()Important: The Puzzel API URLs shown above (https://your-instance.puzzel.com/ticketapi/...) are illustrative. You must retrieve the actual endpoint paths from your Puzzel instance's Swagger UI (Help → API Documentation). The base URL and path structure may differ by instance and region.
Execute the load in strict dependency order: Organisations/Contacts → Tickets → Replies/Notes (chronological) → Attachments.
Step 6: Run Delta Sync
After the initial historical load, run a delta migration to catch conversations created or updated during the migration window:
- Query Unthread for conversations with
updatedAtafter your freeze date - Check which ones already exist in Puzzel using your
source_id → target_idmapping database - Create new tickets or append new messages to existing ones
- Validate the delta before cutting over
- Repeat until the delta is small enough for a final sync during your cutover window
Error Handling and Logging
- Log every HTTP 4xx and 5xx response with full request payload. If a ticket fails to create, all subsequent replies for that ticket will fail. Catch the initial failure, log the Unthread Conversation ID, and skip child records to prevent a cascade of API errors.
- Make creates idempotent by source ID. Track
source_conversation_id → target_ticket_idmappings in a database (SQLite or PostgreSQL). Never deduplicate by title or subject — these are not unique. - Retry 429 and 5xx with exponential backoff and jitter (base: 1s, max: 60s, jitter: ±0.5s).
- Keep a dead-letter queue for failed tickets, attachments, and replies. Process the dead-letter queue after the main migration completes.
- Log counts by object type, status, team, and date window. Compare against source counts continuously.
Edge Cases and Challenges
Slack-Native Data With No Puzzel Equivalent
Unthread stores Slack-specific metadata: slackChannelId, slackTeamId, thread timestamps (ts), bot IDs, and Slack Block Kit formatting. None of this translates to Puzzel. You have two choices:
- Strip it: Convert Slack Block Kit markdown to plain text or HTML. This is the default for most migrations.
- Preserve as metadata: Store Slack-specific fields in a Puzzel Form Field (text area) as a JSON blob for audit or reference purposes. Create a dedicated "Slack Metadata" Form Field.
Multi-Channel Threading
Unthread ties conversations to Slack channels, which are tied to Customers. If a single Slack channel has multiple distinct technical issues (threads), they should already be separate Conversations in Unthread. However, if Unthread's auto-detection grouped them incorrectly, they must be split into separate Puzzel tickets. Identify these during your data audit by checking for Conversations with unusually high message counts.
Duplicate Records
If a user emails support and also opens a Slack thread, Unthread might treat them as separate customers if email matching fails. Puzzel can also auto-create customers from first contact using email/mobile, so preload your master customer list into Puzzel Organisations before importing tickets, or you will create duplicates on both ends. Run a deduplication pass on Unthread Customers/Accounts by email domain before migration.
Knowledge Base Articles
Unthread has a built-in Knowledge Base with articles that power AI deflection. Puzzel Case Management does not include a built-in knowledge base. Export KB articles separately via POST /knowledge-base/articles/list and import them into an external KB tool (e.g., Notion, Confluence, GitBook, or a standalone KB platform that integrates with Puzzel).
Missing History You Assumed Existed
Unthread may never have ingested the email or Slack history you expect. The email connector only creates tickets from new emails after setup. Slack backfill is optional and capped at six months. Verify what actually exists by running a count query (POST /conversations/list with date filters) before you build your migration plan around assumptions.
Manual Ticket Behavior in Puzzel
Customer and organisation data on a manually created Puzzel ticket may not appear in the UI until a conversation (reply) has been added. Factor this into your QA process — tickets imported without replies may appear to have missing Organisation links until an interaction is added.
Attachment Size Limits
| Platform | Max File Size | Max Attachments | Blocked Extensions |
|---|---|---|---|
| Unthread | 20 MB per file | 10 per Slack message | N/A |
| Puzzel | 40 MB total encoded size (per outbound email) | Varies by channel | .exe, .bat, .cmd, .com, .vbs, .js, .scr, .pif, and other executable/script extensions |
If any source files exceed target limits or use blocked extensions, handle them separately: store oversized files in cloud storage and link from the ticket, or rename blocked extensions with a .blocked suffix and note the original extension in the ticket.
Limitations and Constraints
What You'll Lose
| Feature | Unthread | Puzzel CM | Impact |
|---|---|---|---|
| Slack-native threading | Yes | No | Threads become flat chronological ticket replies |
| AI auto-resolution | Built-in | Not equivalent | Must rebuild with Puzzel's automation or third-party AI |
| Knowledge Base | Built-in | No native KB | Export to external tool |
| Conditional form fields | Yes (SimpleCondition) |
No documented equivalent | Fields become always-visible |
| Slack Block Kit formatting | Yes | No | Convert to HTML/plain text |
| Conversation metadata | Rich (Slack channel, team, thread ts) | Limited to form fields | Store as form field or lose |
| Approval Requests | Built-in workflow | Manual rebuild | Recreate in Puzzel's Event Rules |
| Slack reactions | Native | No equivalent | Strip or log as text/JSON |
| Real-time Slack integration | Native | Requires external integration | May need separate Slack-to-Puzzel connector |
Puzzel-Side Constraints
- Agents can populate or update ticket attributes such as team, user, priority, status, tags, categories, forms, and form fields. These are the available ticket attributes — there are no freeform custom objects. Unthread custom fields containing complex JSON payloads must be serialized to text and stored in a form field or appended as internal notes.
- Puzzel workflow features (outbound integrations, event rules, response mappings) may require tenant activation from Puzzel support before you can use them. Request activation at least 1 week before your migration date.
- Closed vs Resolved:
Closedtickets in Puzzel cannot be reopened. Most imported historical closures should land asResolvedunless your service model needs immutable closure. - Retention settings: Puzzel's retention settings can block deletion of recent tickets and customer records (e.g., a 90-day retention window prevents deletion of tickets created within the last 90 days). This directly affects rollback planning — understand your instance's retention configuration before you start loading data.
- Puzzel licensing tiers: Verify that your Puzzel plan includes API access and the number of API ticket channels you need. Some features (Forms, Event Rules, advanced routing) may require specific tier levels. Confirm with your Puzzel account manager.
- Sandbox environment: Request a Puzzel sandbox (test instance) for migration testing. Not all plans include sandbox access — confirm availability early in your planning process.
Validation and Testing
Do not trust a 200 OK response. Validate the data.
Automated Validation
Run these checks after each migration batch:
- Record count comparison: Total conversations extracted vs tickets created in Puzzel. Tolerance: 0% — every conversation must be accounted for (migrated, skipped with reason, or failed with logged error).
- Message count validation: For a random sample of 100+ tickets, verify thread depth (number of replies) matches source conversation message count.
- Attachment validation: Confirm files are downloadable from Puzzel by requesting each attachment URL. Compare file sizes to source.
- Field-level spot checks: Sample at least 5% of tickets (minimum 50) and compare every field value against source data.
- Status distribution: Compare percentage breakdowns of open/closed/in-progress between source and target. These should match within 1%.
- Tag integrity: Verify tag assignments match source data for sampled tickets.
- Organisation linkage: Confirm tickets link to the correct Organisation by cross-referencing Customer/Account IDs.
- Timestamp integrity: Verify
createdAtandclosedAtdates match source data. Check for timezone conversion errors. - Slack markdown residue: Search ticket bodies for raw Slack patterns (
<@U,<#C,<!) to catch untranslated markup.
UAT Process
- Run a test migration with a representative subset (200–500 conversations spanning different ticket types, statuses, and customers) into a Puzzel sandbox
- Have 2–3 agents review tickets in Puzzel and flag discrepancies using a structured feedback template
- Fix transformation logic based on feedback
- Run a full test migration into the sandbox
- Get written sign-off from team leads before production cutover
For a full validation methodology, see our post-migration QA checklist.
Rollback Planning
Before production migration:
- Document every ticket created in Puzzel (store Puzzel ticket IDs in your mapping database alongside Unthread source IDs)
- If rollback is needed, delete imported tickets via the Puzzel API — but check retention settings first. Puzzel's retention window may prevent deletion of recently created tickets. If your retention window is 90 days, you cannot delete tickets created within that period via API.
- Deletion cascading: verify whether deleting a ticket in Puzzel also deletes its replies and attachments, or whether these must be deleted separately.
- Keep Unthread active and accessible during the validation period (minimum 2 weeks post-cutover)
- Do not cancel your Unthread subscription until validation is complete and your team has confirmed the target data in production
Post-Migration Tasks
Rebuild Automations
Unthread's automations, SLA configurations, assignment rules, and AI workflows cannot be exported programmatically. In Puzzel, recreate them using:
- Event Rule groups are a logical way of organising event rules so that they can be easily managed. It is recommended that event rules are organised and contained within event rule groups. Use event rules for automated routing, status changes, and notifications.
- Inbound Rules for email routing and team assignment
- Response and Resolve targets for SLAs (map from Unthread's SLA configuration — document the original SLA targets before decommissioning Unthread)
- Force Category Choice to Resolve: Where used a Category is mandatory to resolve a Ticket. If a ticket is resolved without a category then a warning message will be given to force a category to be included.
Document every Unthread automation before migration. Create a spreadsheet mapping each Unthread automation rule to its Puzzel equivalent (or noting "no equivalent — manual workaround needed").
User Training
Moving from a Slack-native environment to a traditional ticketing dashboard requires behavioral adjustments. Plan training for:
- Ticket queue management and filtering in Puzzel's UI
- Category and Form selection during ticket handling
- Internal notes vs external replies (different workflow from Slack threads)
- Team-based routing and escalation procedures
- SLA tracking and priority management
- Using Puzzel's search and reporting features
Budget 2–4 hours of hands-on training per agent, plus a 1-page quick-reference guide for the first two weeks.
Monitor for Data Inconsistencies
For 2–4 weeks post-migration, keep the Unthread instance active (read-only) and actively monitor:
- Tickets with missing Organisation links
- Duplicate ticket reports from agents
- Form field values that display incorrectly or are truncated
- Attachments that fail to render or download
- New-ticket creation, assignment, and reply delivery in Puzzel
- Agent workflow timing (are tasks taking longer in Puzzel? Identify training gaps)
- SLA compliance rates compared to pre-migration baselines
Best Practices
- Back up everything before migration. Export Unthread data to JSON files and store in versioned cloud storage (S3, GCS). Never migrate without a local copy of the complete source dataset.
- Run at least two test migrations before production. The first reveals mapping errors and missing fields. The second validates your fixes and gives you accurate timing benchmarks.
- Validate incrementally. Don't wait until the end. Validate after each entity type loads. Check counts every 1,000 records during large batches.
- Automate repetitive transformations. Field mapping, status translation, Slack markdown conversion, and tag creation should be scripted — not manual. Manual transformations introduce inconsistency.
- Use idempotent operations. Track which records have been migrated in a persistent mapping database (SQLite minimum, PostgreSQL for large migrations) to avoid duplicates on re-runs. Use Unthread's conversation
idas the deduplication key. - Document every mapping decision. When you map Unthread Ticket Type X to Puzzel Form Y, write it down with the reasoning. Your future self (or your successor) will need it during post-migration debugging.
- Ensure Form visibility. Puzzel Forms are governed by team settings. A Form that exists but isn't visible to the assigned Team will cause import failures that are hard to diagnose. Verify Form→Team visibility in the sandbox before production load.
- Plan for token refresh. If using OAuth authentication with Puzzel, implement automatic token refresh in your loading script. A 100-hour migration will outlast any access token.
Sample Data Mapping Table
| Unthread Field | Puzzel CM Field | Transformation |
|---|---|---|
conversation.id |
External Reference | Store as-is for traceability and idempotency |
conversation.title |
Ticket Subject | Direct map. Truncate to Puzzel's max subject length if needed. |
conversation.status |
Ticket Status | open→New, in_progress→Assigned, on_hold→Pending, closed→Resolved |
conversation.priority (3,5,7,9) |
Priority | 3→Critical, 5→High, 7→Medium, 9→Low |
conversation.createdAt |
Created Date | ISO 8601 direct map (UTC) |
conversation.closedAt |
Resolved Date | ISO 8601 direct map (UTC). Null if still open. |
conversation.assignedToUserId |
Assigned Agent | Map via users lookup table (Unthread userId → email → Puzzel agent) |
conversation.tags [].name |
Tags | Create tags in Puzzel first. Match by exact name. |
conversation.ticketTypeId |
Form | Map Ticket Type ID → Puzzel Form ID via mapping table |
conversation.ticketTypeFields |
Form Field values | Map field IDs to Form Field IDs. Handle type translation. |
customer.name |
Organisation Name | Direct map after deduplication |
account.emailsAndDomains |
Organisation custom attribute | Store as semicolon-delimited list or custom attribute |
message.text |
Reply body | Convert Slack mrkdwn → HTML, resolve user mentions, strip Block Kit |
message.metadata.eventType |
Note type indicator | internal_note → Internal note; all others → external reply |
message.createdAt |
Reply timestamp | ISO 8601 (UTC). Preserve original timestamp for audit trail. |
files [].name |
Attachment filename | Download from Unthread during extraction, re-upload to Puzzel after ticket creation |
Making the Right Call
Migrating from Unthread to Puzzel Case Management is a schema translation between two fundamentally different support architectures. The biggest risks are:
- Message threading loss — Slack threads flatten to chronological replies
- Slack-specific data without Puzzel equivalents — Block Kit, reactions, channel metadata
- Customer/Account deduplication — Two entity types merging into one
- Source data gaps — Email and Slack history may not be as complete as assumed
- Attachment URL expiry — Slack-backed files must be downloaded during extraction
Plan for 2–4 weeks of total effort for a mid-size migration (5,000–25,000 conversations). Budget for at least two full test runs. Do not cancel your Unthread subscription until your team has validated the target data in production for at least 2 weeks.
If your team has the engineering capacity, the API-to-API approach gives you full control. If not — or if the dataset is large and the timeline is tight — a managed migration gets you there faster with less risk.
For more on help desk migration planning, see our help desk data migration checklist and data mapping guide.
Frequently Asked Questions
- Can I export data from Unthread as CSV?
- No. Unthread does not offer a native CSV export for ticket data. All data extraction must go through the Unthread REST API (api.unthread.io/api/). List endpoints are paginated at 100 records per page using cursor-based pagination. You'll need a script to extract conversations, messages, customers, accounts, tags, and ticket types via POST /list endpoints.
- Does Puzzel Case Management support bulk ticket import via API?
- Puzzel Case Management supports ticket creation through API ticket channels configured in Settings > Ticket Channels > API with Basic Token or OAuth authentication. Tickets are created one at a time — there is no documented bulk import endpoint, so you'll need to loop through records with rate-limit handling.
- How long does an Unthread to Puzzel migration take?
- A typical mid-size migration (5,000–25,000 conversations) takes 2–4 weeks: 2–3 days for planning and mapping, 3–5 days for extraction, 3–5 days for transformation and test loading, 2–3 days for production loading and delta sync, and 2–3 days for validation and cutover. Smaller datasets under 1,000 conversations can be completed in 1–2 weeks.
- What data is lost when migrating from Unthread to Puzzel?
- You'll lose Slack-native threading context (threads become flat replies), Slack Block Kit formatting (must convert to HTML), conditional Ticket Type Field visibility logic, the built-in Knowledge Base (Puzzel CM has no native KB), AI auto-resolution workflows, and Slack-specific metadata like channel IDs and thread timestamps. Automations and SLA rules must be rebuilt manually in Puzzel's Event Rules.
- Is there a third-party tool that migrates Unthread to Puzzel automatically?
- No. At the time of writing, no third-party migration tool supports the Unthread-to-Puzzel Case Management pairing natively. You'll need either custom API scripts, a middleware platform like Pipedream for small ongoing syncs, or a managed migration service.

