Kustomer to Unthread Migration: The CTO's Technical Guide
Technical guide to migrating from Kustomer to Unthread. Covers API extraction, KObject mapping, rate limits, data model translation, and step-by-step process.
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
Kustomer to Unthread Migration: The CTO's Technical Guide
TL;DR — Kustomer to Unthread Migration
A Kustomer to Unthread migration is a medium-to-high complexity data-model translation project. Kustomer's omnichannel, customer-centric timeline — where Conversations, Messages, Notes, and KObjects all hang off a unified Customer record — must be restructured into Unthread's Conversation-centric, Slack-native model. The biggest risks: KObject data loss (Unthread has no equivalent of freeform custom objects), incomplete message extraction (CSV exports only include previews, not full bodies), and attachment URL expiry (Kustomer's signed S3 URLs expire within approximately 15–60 minutes). Kustomer's machine-user API rate limit defaults to 100 RPM, and its Search API caps at 100 pages per query — requiring date-range windowing for large datasets. There is no native migration path between these platforms. Teams with under 5,000 conversations and no KObjects can self-serve with scripts in 1–2 weeks. Migrations of 50K+ conversations at 100 RPM with ~8 messages per conversation require approximately 70–100 hours of continuous API extraction time alone — plan accordingly. Anything larger or more complex benefits from a managed migration service.
What Is a Kustomer to Unthread Migration?
A Kustomer to Unthread migration moves your support operation from a customer-centric CRM platform to a Slack-native conversational helpdesk. You are extracting Customers, Conversations, Messages, Notes, KObjects, Tags, Users, Teams, and Knowledge Base articles from Kustomer and loading them into Unthread as Customers, Accounts, Conversations, Messages, Tags, Ticket Types with custom fields, and Knowledge Base Articles.
This is not a lift-and-shift. The two platforms serve different market segments and have fundamentally different data architectures. Kustomer is a full CRM-based service platform built around a unified customer timeline — every interaction, custom object, and data point hangs off a single Customer record. Unthread is built around conversational ticketing inside Slack, with email, portals, tags, ticket types, and project-level routing layered on top. (help.kustomer.com)
Why teams move from Kustomer to Unthread
- Slack-native workflow. B2B SaaS teams already working in Slack want their ticketing system there natively instead of forcing context-switches to a separate CRM UI. Unthread manages Slack Connect channels as a first-class support surface; Kustomer treats Slack as a secondary integration.
- Simplicity over CRM depth. Kustomer's KObject/Klass data model is powerful but heavy — it requires dedicated administrators. Teams that don't need custom CRM objects want a leaner tool tailored for engineering and product-led support.
- Cost structure. Kustomer requires annual contracts with an 8-seat minimum and quote-based pricing. Unthread's pricing aligns better with smaller B2B teams managing fewer, higher-value accounts.
- Internal support focus. Teams shifting from external customer service to internal IT/HR/ops support find Unthread purpose-built for that use case.
- Post-acquisition uncertainty. Kustomer was acquired by Meta for ~$1B in 2020, then spun back out in May 2023 at a ~$250M valuation. Some teams re-evaluated vendor risk during this period.
Should you migrate? A decision framework
Before committing to migration, evaluate whether Unthread's architecture supports your operational requirements:
| Factor | Kustomer is likely still the right choice | Unthread is likely the right choice |
|---|---|---|
| KObject dependency | Agents reference 3+ KObject types during ticket resolution | No KObjects, or data lives in an external CRM |
| Channel mix | Heavy SMS, social, voice alongside email | Primarily Slack + email |
| Team size | 20+ agents with complex routing and SLAs | 2–15 agents, flat or single-team structure |
| Custom object complexity | Nested/relational KObjects (e.g., Order → LineItem → Return) | Flat attributes that map to dropdown/text fields |
| Primary buyer | B2C with high-volume consumer support | B2B with fewer, higher-value accounts |
| Workflow complexity | 50+ business rules with multi-step API actions | <20 automation rules with simple triggers |
If your team relies heavily on KObject relational data, multi-channel threading, or complex workflow orchestration, Unthread may not be a suitable replacement — regardless of migration feasibility.
Core architecture differences
| Dimension | Kustomer | Unthread |
|---|---|---|
| Data model | Customer-centric timeline; everything hangs off a Customer record | Conversation-centric; Customers and Accounts linked to Conversations |
| Custom objects | KObjects (freeform custom Klasses with schema definitions) | Ticket Type Fields (structured fields on Ticket Types) |
| Primary channel | Omnichannel (email, chat, SMS, social, voice) | Slack-native with email, web chat, Teams support |
| API base | api.kustomerapp.com/v1/ |
api.unthread.io/api/ |
| Auth method | Bearer token (API key) | X-Api-Key header |
| Pagination | Cursor-based, 100 pages max per search query | Cursor-based, 100 records per page |
| Target buyer | Mid-market/enterprise B2C (ecommerce, retail, travel) | IT, HR, and ops teams in 200–2,000 employee companies |
This architectural gap is why a Kustomer migration is a simplification project. You are not recreating a customer-service CRM object graph inside Unthread. You are deciding which history matters operationally enough to become active ticketing data, and which should stay in a CRM, warehouse, or archive.
Migration Approaches
There is no native integration between Kustomer and Unthread. No import wizard, no pre-built connector, no one-click migration path. Every approach involves extracting data from Kustomer, transforming it, and loading it into Unthread.
1. CSV Export + API Import (Hybrid)
How it works: Use Kustomer's Export Buddy and reporting exports to pull CSV files for Users, Teams, Tags, and basic Conversation/Customer data. Then use Unthread's REST API to create records programmatically.
When to use it: Small datasets under 5,000 conversations where full message bodies aren't critical.
Pros:
- Low engineering effort on the extraction side
- Export Buddy handles team structure and snippets quickly
Cons:
- Kustomer's CSV exports only include message previews, not full message bodies — you lose conversation history detail (help.kustomer.com)
- Reporting exports cap at approximately 50,000 records before requiring date-range splitting
- No way to export KObjects via CSV
- Still requires API scripting on the Unthread import side
- Attachments and inline images are lost
Complexity: Low to Medium
2. API-to-API Migration (Recommended for most teams)
How it works: Extract all data from Kustomer's REST API (/v1/customers, /v1/conversations, /v1/conversations/{id}/messages, /v1/kobjects/{klassName}), transform it into Unthread's schema, and load it via Unthread's API (POST /conversations, POST /customers, POST /accounts, POST /tags).
When to use it: Any migration where you need full message bodies, KObject data, attachments, or complete conversation history.
Pros:
- Full control over extraction, transformation, and loading
- Preserves complete message bodies and metadata
- Handles KObjects, attachments, and relationships
- Scriptable and repeatable for test migrations
Cons:
- Kustomer's machine-user rate limit defaults to 100 RPM — extracting 50K conversations with messages takes approximately 70–100 hours of continuous extraction (help.kustomer.com)
- Pagination caps at 100 pages per search query; you must use
updated_atcursor-windowing to get all records - Requires development effort (Python/Node.js scripting, typically 40–80 engineering hours)
- Kustomer's signed S3 attachment URLs expire within approximately 15–60 minutes — your script must download and re-upload files before expiry
Complexity: High
3. Middleware Platforms (Zapier, Make, Pipedream)
How it works: Use an integration platform to connect Kustomer triggers/actions with Unthread actions. Pipedream has an existing Unthread integration with actions for creating Customers and managing conversations.
When to use it: Ongoing sync of new records during a phased cutover, or very small one-time transfers under 500 records.
Pros:
- No code required for basic workflows
- Good for event-driven delta sync after a bulk migration
Cons:
- Not designed for bulk historical migration
- Rate limits on both sides compound — you'll hit Kustomer's 100 RPM ceiling quickly
- Complex transformations (KObject flattening, relationship rebuilding) aren't feasible in visual workflow builders
- Cost scales with execution volume
- Poor observability at high volume; event ordering issues with attachments
Complexity: Low for small volumes, impractical for bulk migration
4. Custom ETL Pipeline
How it works: Build an extract-transform-load pipeline using Python, Node.js, or a data orchestration tool (Airflow, Dagster). Extract from Kustomer API, transform in a staging database (PostgreSQL, SQLite), load into Unthread API.
When to use it: Enterprise migrations with 50K+ conversations, complex KObject schemas, multi-brand setups, or regulatory requirements for audit trails.
Pros:
- Full control over transformation logic, error handling, and retry behavior
- Staging database enables validation before loading
- Can run incremental syncs for phased cutovers
- Easiest to extend into long-term sync
Cons:
- Highest engineering investment (80–160+ hours for complex schemas)
- Requires deep understanding of both platform schemas
- You own the maintenance and debugging
Complexity: High
5. Managed Migration Service
How it works: A migration service handles the full pipeline — extraction, mapping, transformation, loading, validation — based on your requirements.
When to use it: When engineering bandwidth is limited, the dataset is complex (KObjects, large attachment volumes), or you can't afford data loss.
Pros:
- Fastest time to completion
- Migration team handles edge cases: duplicates, broken relationships, rate limit management, attachment re-hosting
- Validation and rollback planning included
- SLA-backed timelines
Cons:
- External cost (typically $5K–$25K+ depending on volume and complexity)
- Requires clear scope definition upfront
- Less internal knowledge transfer
Complexity: Low (for your team)
Approach Comparison
| Approach | Best For | Full Messages | KObjects | Attachments | Scalability | Engineering Hours |
|---|---|---|---|---|---|---|
| CSV + API | Small, simple datasets | ❌ Previews only | ❌ | ❌ | Low | 10–20 |
| API-to-API | Most migrations | ✅ | ✅ | ✅ (requires S3 handling) | Medium | 40–80 |
| Middleware | Ongoing sync, tiny volumes | ❌ | ❌ | ❌ | Very low | 5–10 |
| Custom ETL | Enterprise, complex schemas | ✅ | ✅ | ✅ | High | 80–160+ |
| Managed service | Any complexity, limited bandwidth | ✅ | ✅ | ✅ | High | 5–10 (your side) |
Recommendations by scenario:
- Small team, <5K conversations, no KObjects: API-to-API with a Python script. ~1–2 weeks.
- Enterprise, 50K+ conversations, KObjects, attachments: Custom ETL or managed migration. 4–8 weeks.
- No engineering bandwidth: Managed migration service.
- Ongoing sync during phased cutover: Middleware for new records + API-to-API for historical backfill.
When a Managed Migration Service Makes Sense
DIY migrations fail in predictable ways:
- Broken relationships. Conversations imported without their parent Customer references become orphaned records in Unthread. Rebuilding those links after the fact means re-processing the entire dataset.
- Incomplete message history. Using CSV exports instead of the API means agents lose full conversation context — right when they need it most during the transition.
- KObject data loss. Without a deliberate transformation strategy, KObject data simply gets dropped. There is no automated way to map Kustomer's freeform Klasses to Unthread's Ticket Type Fields.
- Attachment failures. Kustomer stores attachments in AWS S3 with signed URLs that expire within approximately 15–60 minutes. Standard ETL tools fail because they don't handle the download-and-reupload cycle within the expiry window.
- Automation storms during import. If Unthread fires webhooks or automation events on record creation, a bulk import without disabling automations triggers thousands of unwanted notifications or workflow executions. Disable all Unthread automations before starting the import and re-enable them post-migration.
- Hidden engineering cost. What looks like a "two-day scripting project" turns into weeks of debugging pagination edge cases, rate limit backoff, and malformed data. Engineers end up reverse-engineering undocumented API quirks instead of shipping product.
A managed migration service handles these failure modes systematically, including rate-limit-aware batching, KObject transformation logic, relationship preservation, attachment streaming, pre-load validation with record-count reconciliation, and zero-downtime cutover planning with delta syncs.
Pre-Migration Planning
Data audit checklist
Before writing any code, inventory what you actually have in Kustomer:
- Customers — total count, custom attributes defined, linked Companies/Orgs
- Conversations — total count by status (open, snoozed, done), channels used (email, chat, SMS, social)
- Messages — total count, average messages per conversation, attachment frequency
- Notes — internal notes linked to conversations or customers
- KObjects — list every Klass definition, count instances per Klass, identify which are actively used by agents
- Tags — full list, identify unused or duplicate tags
- Users and Teams — active vs. inactive agents, team structure
- Knowledge Base — article count, categories, published vs. draft status
- Snippets/Shortcuts — canned responses to recreate in Unthread
- Business Rules and Workflows — these cannot be exported; document them manually with screenshots
- API key permissions — verify your Kustomer API key has read access to:
customers,conversations,messages,kobjects,teams,users,tags, andknowledge-base. Machine-user API keys requireorg.adminor per-resource read scopes
Estimate extraction time
Calculate your expected API extraction duration before starting:
| Dataset Size | Messages (avg 8/convo) | API Calls Needed | Time at 100 RPM |
|---|---|---|---|
| 5,000 conversations | 40,000 | ~45,000 | ~7.5 hours |
| 25,000 conversations | 200,000 | ~225,000 | ~37.5 hours |
| 50,000 conversations | 400,000 | ~450,000 | ~75 hours |
| 100,000 conversations | 800,000 | ~900,000 | ~150 hours |
These estimates include listing conversations, fetching messages per conversation, and fetching customer records. They do not include attachment downloads, KObject extraction, or Unthread loading time.
Identify what not to migrate
Not everything deserves a spot in your new platform:
- Conversations closed more than 2–3 years ago with no recent activity
- Test customers and internal QA conversations
- KObjects from uninstalled or deprecated integrations
- Duplicate customer records (Kustomer's merge feature may not have caught everything)
- Spam or auto-closed conversations with zero agent replies
Migration strategy
| Strategy | When to use | Typical timeline |
|---|---|---|
| Big bang | Small dataset (<10K conversations), weekend cutover acceptable | 1–2 weeks prep, 1 weekend cutover |
| Phased | Large dataset, multiple teams, need to validate per-team before full cutover | 3–6 weeks |
| Incremental | Ongoing operations can't pause; backfill historical data while new tickets go to Unthread | 4–8 weeks |
For big bang cutovers, run a full historical migration during the week. On Friday evening, run a delta sync to capture tickets created during the migration window. On Monday morning, agents log into Unthread.
GDPR and data residency considerations
Moving customer PII between platforms triggers data protection obligations:
- Data Processing Agreement (DPA). Ensure Unthread's DPA covers your use case. If you're migrating from Kustomer's EU instance (
api.prod2.kustomerapp.com), confirm Unthread's data residency meets your requirements. - Lawful basis for transfer. Your existing lawful basis for processing (contract, legitimate interest, consent) typically extends to migration between processors, but consult your DPO.
- Right to erasure. After migration, deletion requests must be honoured in Unthread. Ensure your Kustomer deletion logs carry over or are documented.
- Data minimisation. Migration is a natural opportunity to purge data you no longer need. Don't migrate PII you're not legally entitled to retain.
Kustomer's Business Rules, Workflows, Saved Searches, and routing queue settings are not included in any export format. Document these manually — screenshot workflow conditions and actions, copy snippet text, note SLA thresholds — before you cancel your subscription. Kustomer provides no read-only access period after the subscription ends. (help.kustomer.com)
Data Model and Object Mapping
This is where the migration gets hard. Kustomer and Unthread have fundamentally different data architectures, and several mappings are lossy.
Object-level mapping
| Kustomer Object | Unthread Object | Notes |
|---|---|---|
| Customer | Customer (Channel) | Map displayName, emails, phones. Email is the unique identifier |
| Company/Org | Account | Unthread Accounts support emailsAndDomains, slackChannelIds. Use domain as the primary key for automatic Slack channel mapping |
| Conversation | Conversation | Map status, assignedTo, tags, createdAt. Set type (slack, email, or triage) at creation — it is immutable |
| Message | Message | Import as messages within a Conversation. Use body field with markdown |
| Note | Message (private note) | Set isPrivateNote: true for internal notes. Verify these stay hidden from customers |
| KObject (custom) | Ticket Type Fields | Flatten KObject attributes into Ticket Type Field values. Lossy transformation |
| Tag | Tag | Direct 1:1 mapping. Create tags first, then link to Conversations |
| User (agent) | User | Users sync from Slack workspace; you cannot create users via API. Match by email |
| Team | User Team | Teams also sync from Slack; manual setup required |
| Knowledge Base Article | Knowledge Base Article | Map title, content. Unthread articles support url and userGroupIds |
| Snippet/Shortcut | (manual rebuild) | No API import; recreate manually in Unthread |
| Business Rule/Workflow | Automation | Must be rebuilt from scratch using Unthread's Automation API |
Field-level mapping
| Kustomer Field | Unthread Field | Transformation |
|---|---|---|
customer.attributes.displayName |
customer.name |
Direct map |
customer.attributes.emails [0].email |
customer.emailDomains [] |
Extract domain for channel-level; full email for Account |
customer.relationships.org.data.id |
account.externalCrmMetadata.id |
Lookup Account ID |
conversation.attributes.status |
conversation.status |
Map: open→open, snoozed→on_hold, done→closed |
conversation.attributes.priority |
conversation.priority |
Map to numeric scale: critical→9, high→7, normal→5, low→3 |
conversation.attributes.assignedUsers [0] |
conversation.assignedToUserId |
Unthread supports single assignee; pick primary. Match by email |
conversation.attributes.tags [] |
Tag links | Create tags first, link via POST /tags/{id}/conversations/create-links |
message.attributes.body |
message.body.value |
Use API for full body, not CSV preview. Strip HTML or set type to html |
message.type == 'note' |
message.isPrivateNote |
Boolean toggle based on type |
message.attributes.createdAt |
message.created_at |
ISO 8601 timestamp preservation |
kobject.attributes.* |
ticketTypeFields.* |
Requires per-Klass transformation logic |
Handling KObjects — the hard part
Kustomer's KObjects are schema-defined custom objects (instances of "Klasses") that can model orders, subscriptions, claims, or any business entity. Each KObject has its own attributes, relationships, and timeline rendering.
Unthread has no equivalent. The closest construct is Ticket Type Fields — structured fields (short-answer, long-answer, multi-select, single-select, checkbox, user-select) attached to a Ticket Type.
Your options:
- Flatten to Ticket Type Fields. Create a Ticket Type per Klass, with fields for each KObject attribute. Works for simple schemas — for example, flattening a "Recent Orders" KObject into
last_order_idandlast_order_datefields. Breaks for nested or relational KObject data (e.g., Order → LineItem → Return chains). - Store externally. Migrate KObject data into your CRM (HubSpot, Salesforce) and let Unthread surface it via its native CRM integration. Or export to a database and link via an external ID in Unthread's
metadatafield. - Drop. If KObjects are only used for reporting or aren't actively referenced by agents during ticket resolution, archive them outside the helpdesk entirely.
Decision criteria: If agents open KObject data on more than 20% of tickets, option 1 or 2 is required. If KObjects are purely historical/analytical, option 3 is sufficient.
There is no lossless way to migrate complex KObject schemas to Unthread. If your agents rely on KObject data during ticket resolution, evaluate whether Unthread's Ticket Type Fields can support your workflow before committing to the migration.
Migration Architecture
Data flow
Kustomer REST API → Extract Scripts → Staging DB → Transform Scripts → Unthread REST API
↓ ↓ ↓
Rate-limited Schema mapping Automations disabled
(100 RPM default) KObject flattening during bulk import
Cursor-windowed Relationship linking
Kustomer API extraction details
- Base URL:
https://api.kustomerapp.com/v1/(US);https://api.prod2.kustomerapp.com/v1/(EU) - Auth:
Authorization: Bearer <api_key> - Required API key scopes: Read access to
customers,conversations,messages,kobjects,teams,users,tags,knowledge-base. Machine-user keys withorg.adminscope provide full read access; alternatively, configure per-resource read permissions - Rate limit: Machine users default to 100 RPM. Enterprise plans get 1,000 RPM for webhooks/formhooks; Ultimate gets 2,000 RPM (help.kustomer.com)
- Pagination: Cursor-based with
page [size](max 100) andpage [after]. Search endpoints cap at 100 pages per query — useupdated_atwindowing to get beyond this limit - Archive Search: Standard Search API returns records updated within the past 2 years. For older data, use Archive Search (help.kustomer.com)
- Key endpoints:
/v1/customers,/v1/conversations,/v1/conversations/{id}/messages,/v1/kobjects/{klassName},/v1/teams,/v1/users - Attachments: Stored in AWS S3 with signed URLs. These URLs expire within approximately 15–60 minutes depending on the attachment type — your script must download files immediately and re-upload to Unthread before expiry
Unthread API loading details
- Base URL:
https://api.unthread.io/api/ - Auth:
X-Api-Key: <service_account_key> - Pagination: Cursor-based, 100 records per page on list endpoints
- Key endpoints:
POST /conversations,POST /customers,POST /accounts,POST /tags,POST /knowledge-base/articles,POST /ticket-types - Attachments: Multipart form-data with a
jsonfield +attachmentsfields. Max 10 files per message (Slack's limit), 20MB per file - Rate limits: Not publicly documented. In empirical testing, sustained throughput of 30–60 RPM is generally safe; higher concurrency may trigger throttling. Build in adaptive backoff with exponential retry. Test throughput in staging before production runs (docs.unthread.io)
- Conversation type is immutable: You set
type(slack, email, triage) when creating a Conversation and cannot change it afterward. Choose carefully during transformation - No bulk import endpoint: Every record must be created individually via REST calls
- Webhook/automation behavior: Unthread fires events (webhooks, automations) on record creation. Disable all automations before bulk import to prevent notification storms and unwanted workflow executions. Re-enable after migration and validation are complete
Step-by-Step Migration Process
Step 1: Prepare the environment
Before extracting data:
- Verify your Kustomer API key has the required scopes (see extraction details above)
- Disable all Unthread automations, webhook integrations, and Slack notification rules
- Set up a staging database (PostgreSQL recommended) with tables mirroring both source and target schemas
- Create a
migration_logtable to track:source_id,target_id,object_type,status,error_message,created_at
Step 2: Extract data from Kustomer
Start with reference data (Tags, Users, Teams), then move to Customers, Conversations, and Messages. Window your extractions by date range to handle the 100-page pagination ceiling.
import requests
import time
KUSTOMER_API = "https://api.kustomerapp.com/v1"
KUSTOMER_TOKEN = "your_kustomer_api_key"
HEADERS = {"Authorization": f"Bearer {KUSTOMER_TOKEN}"}
def extract_customers(since_date=None):
"""Extract customers with updated_at windowing for pagination beyond 100 pages."""
customers = []
page_cursor = None
base_params = {"page[size]": 100, "sort": "updatedAt"}
if since_date:
base_params["updatedAt[gte]"] = since_date
while True:
params = {**base_params}
if page_cursor:
params["page[after]"] = page_cursor
resp = requests.get(f"{KUSTOMER_API}/customers", headers=HEADERS, params=params)
if resp.status_code == 429:
retry_after = int(resp.headers.get("Retry-After", 60))
time.sleep(retry_after)
continue
if resp.status_code == 403:
raise PermissionError("API key lacks read access to /customers. "
"Verify org.admin or customers.read scope.")
resp.raise_for_status()
data = resp.json()
batch = data.get("data", [])
if not batch:
break
customers.extend(batch)
# Handle 100-page limit: window by updated_at
links = data.get("links", {})
next_link = links.get("next")
if not next_link:
last_updated = batch[-1]["attributes"]["updatedAt"]
base_params["updatedAt[gte]"] = last_updated
page_cursor = None
continue
page_cursor = next_link.split("page[after]=")[-1].split("&")[0]
time.sleep(0.6) # Stay under 100 RPM
return customersStep 3: Extract conversations and full message bodies
CSV exports only give message previews. Use the API to get complete message content.
def extract_messages(conversation_id):
"""Extract full message bodies for a conversation."""
messages = []
page_cursor = None
while True:
params = {"page[size]": 100}
if page_cursor:
params["page[after]"] = page_cursor
resp = requests.get(
f"{KUSTOMER_API}/conversations/{conversation_id}/messages",
headers=HEADERS, params=params
)
if resp.status_code == 429:
retry_after = int(resp.headers.get("Retry-After", 60))
time.sleep(retry_after)
continue
resp.raise_for_status()
data = resp.json()
batch = data.get("data", [])
if not batch:
break
messages.extend(batch)
next_link = data.get("links", {}).get("next")
if not next_link:
break
page_cursor = next_link.split("page[after]=")[-1].split("&")[0]
time.sleep(0.6)
return messagesStep 4: Download attachments immediately
Kustomer attachment URLs are signed S3 URLs that expire within approximately 15–60 minutes. Download them during message extraction, not as a separate pass.
import os
import hashlib
def download_attachment(url, dest_dir="./attachments"):
"""Download attachment before S3 URL expires. Returns local path."""
os.makedirs(dest_dir, exist_ok=True)
filename = hashlib.sha256(url.encode()).hexdigest()[:16]
local_path = os.path.join(dest_dir, filename)
resp = requests.get(url, stream=True, timeout=30)
resp.raise_for_status()
with open(local_path, "wb") as f:
for chunk in resp.iter_content(chunk_size=8192):
f.write(chunk)
file_size = os.path.getsize(local_path)
if file_size > 20 * 1024 * 1024: # 20MB Unthread/Slack limit
print(f"WARNING: Attachment {filename} is {file_size/1024/1024:.1f}MB, "
f"exceeds Unthread's 20MB limit")
return local_pathStep 5: Transform data
Map Kustomer records to Unthread's schema. Handle status mapping, priority conversion, and KObject flattening in a staging layer.
from datetime import datetime
def transform_conversation(kustomer_convo, kustomer_messages, tag_id_map):
"""Transform a Kustomer conversation + messages into Unthread format."""
attrs = kustomer_convo["attributes"]
status_map = {
"open": "open",
"snoozed": "on_hold",
"done": "closed",
}
priority_map = {"critical": 9, "high": 7, "normal": 5, "low": 3}
first_message = kustomer_messages[0] if kustomer_messages else None
initial_text = first_message["attributes"].get("body", "") if first_message else ""
return {
"type": "email", # or "triage" for internal-only conversations
"markdown": initial_text,
"status": status_map.get(attrs.get("status"), "closed"),
"title": attrs.get("name", attrs.get("subject", "Migrated conversation")),
"priority": priority_map.get(attrs.get("priority"), 5),
"metadata": {
"kustomer_id": kustomer_convo["id"],
"migrated_at": datetime.utcnow().isoformat(),
"original_channel": attrs.get("channels", [None])[0],
},
}Step 6: Load into Unthread
Create records in dependency order: Tags → Accounts → Customers → Ticket Types → Conversations → Messages → KB Articles. This ensures all relationship references exist when Conversations and Messages are imported.
UNTHREAD_API = "https://api.unthread.io/api"
UNTHREAD_KEY = "your_unthread_api_key"
UT_HEADERS = {"X-Api-Key": UNTHREAD_KEY, "Content-Type": "application/json"}
def create_unthread_conversation(convo_data, max_retries=3):
for attempt in range(max_retries):
resp = requests.post(
f"{UNTHREAD_API}/conversations",
headers=UT_HEADERS,
json=convo_data
)
if resp.status_code == 429:
backoff = min(2 ** attempt * 10, 120)
time.sleep(backoff)
continue
if resp.status_code >= 500:
time.sleep(2 ** attempt * 5)
continue
resp.raise_for_status()
return resp.json()
raise Exception(f"Failed to create conversation after {max_retries} retries")
def create_unthread_message(conversation_id, message_data):
resp = requests.post(
f"{UNTHREAD_API}/conversations/{conversation_id}/messages",
headers=UT_HEADERS,
json=message_data
)
resp.raise_for_status()
return resp.json()Step 7: Link tags and rebuild relationships
After loading, link Tags to Conversations using Unthread's tag-linking endpoints:
def link_tags_to_conversation(tag_id, conversation_ids):
resp = requests.post(
f"{UNTHREAD_API}/tags/{tag_id}/conversations/create-links",
headers=UT_HEADERS,
json=conversation_ids
)
resp.raise_for_status()Store the kustomer_id in every Unthread record's metadata field. This makes cross-referencing and debugging possible after cutover, and lets you safely re-run the migration after failures without creating duplicates.
Error handling and idempotency
- Retry 429 and 5xx responses with exponential backoff; respect the
Retry-Afterheader from Kustomer - Maintain a
kustomer_id → unthread_idlookup table for every loaded object - Dead-letter attachment failures separately from conversation creation — don't block conversation import on a single failed attachment
- Log batch totals: conversations, messages, notes, attachments, and tags
- Before creating any record, check if one with that
kustomer_idalready exists in your lookup table — this makes your import idempotent and safe to re-run after partial failures - Write failed records to a separate error log with the full request payload for debugging
Edge Cases and Challenges
-
Slack mapping constraints. You cannot insert historical Kustomer emails into a live Slack Connect channel. Historical tickets must exist in Unthread's dashboard as closed/archived web tickets (type
emailortriage). Unthread does offer a 6-month channel backfill when connecting Slack channels, but this imports past Slack threads as closed tickets — it is not a substitute for historical Kustomer data. -
Duplicate customer records. Kustomer allows multiple Customer records with the same email if web tracking and email tracking failed to merge. Deduplicate before loading — merge records with matching emails into a single Unthread Customer. Use the most recently updated Kustomer record as the canonical source.
-
Multi-channel conversations. A single Kustomer Conversation can span email, chat, and SMS. Unthread Conversations have a single
type(slack, email, or triage), and that type is immutable after creation. Pick a primary channel based on the first message's channel, or split multi-channel conversations into separate Unthread records linked via metadata. -
Inline images. Kustomer users frequently paste images directly into chat. These are stored as base64 strings or S3 links within the HTML body. Your parser must regex these out, upload them as attachments, and rewrite the
<img>tags before pushing to Unthread. -
100-page pagination limit. Kustomer's Search API and Archive Search return a maximum of 100 pages per query. For datasets beyond ~10,000 records, use
updated_atwindowing: query with a date filter, process results, then start a new query from the lastupdated_atvalue. -
Older history (>2 years). Kustomer's standard Search API only returns records updated within the past 2 years. For older data, use Archive Search alongside the standard endpoint. (help.kustomer.com)
-
Users cannot be created via Unthread API. Unthread Users sync from your Slack workspace. Ensure all agents are in your Slack workspace and synced to Unthread before importing conversations with assignee references. Build a
kustomer_user_id → unthread_user_idmapping by matching email addresses. -
Email threading. Unthread groups email threads automatically, but BCC or forwarding edge cases can split threads. Test migrated reply behavior, not just historical display.
-
Messages with >10 attachments. Slack enforces a 10-file-per-message limit. Messages exceeding this must be split into multiple Unthread messages, with a metadata flag linking them to the same source message.
-
HTML body sanitization. Kustomer stores rich HTML email bodies. Unthread's markdown renderer may not support all HTML tags. Test with a representative sample and build a sanitization step that converts unsupported tags (e.g.,
<table>,<style>) to readable markdown.
Limitations and Constraints
What you will lose
- KObject richness. Unthread's Ticket Type Fields support basic types (short-answer, long-answer, single-select, multi-select, checkbox, user-select). Nested objects, relational KObject chains, and custom timeline layouts have no equivalent.
- Multi-channel threading. Kustomer renders multi-channel conversations on a single timeline. In Unthread, threading follows Slack's thread model.
- Business Rules and Workflows. Not exportable from Kustomer. Must be manually documented and rebuilt as Unthread Automations.
- SLA configurations. Kustomer's SLA policies don't export. Reconfigure SLAs in Unthread's support step settings.
- Reporting history. Historical analytics data does not transfer. Export Kustomer reports before migration for archival.
- Multi-brand routing. Kustomer's timeline allows complex multi-brand routing. Unthread uses project-based routing which may require restructuring.
- Satisfaction surveys. Kustomer's CSAT survey results and response data do not have an import target in Unthread. Export for archival.
Kustomer API constraints
- Machine user rate limit: 100 RPM default (Enterprise: 1,000 RPM for webhooks/formhooks; Ultimate: 2,000 RPM)
- Pagination ceiling: 100 pages per search query (~10,000 records at 100 per page)
- Search recency: Standard Search returns records updated in the last 2 years; Archive Search required for older data
- EU vs US hosting: Different API domains —
api.kustomerapp.comfor US,api.prod2.kustomerapp.comfor EU - Attachment URLs: Signed S3 URLs that expire within approximately 15–60 minutes
- Required scopes:
org.adminor per-resource read permissions forcustomers,conversations,messages,kobjects,teams,users,tags
Unthread API constraints
- No bulk import endpoint. Every record must be created individually via REST calls
- Rate limits not published. Empirical safe throughput: ~30–60 RPM sustained. Build adaptive backoff
- Conversation type is immutable. Set at creation; cannot be changed afterward
- Attachment limits: 10 files per message, 20MB per file (Slack's constraints)
- Users cannot be created via API. Must sync from Slack workspace
- Record creation fires events. Webhooks and automations trigger on import — disable before bulk loading
Validation and Testing
Never assume a 200 OK response means the data is correctly formatted.
Record count reconciliation
After migration, compare totals:
| Object | Kustomer Count | Unthread Count | Delta | Acceptable Variance |
|---|---|---|---|---|
| Customers | X | X | 0 | 0 (after deduplication) |
| Conversations | X | X | 0 | 0 |
| Messages | X | X | 0 | 0 |
| Tags | X | X | 0 | 0 |
| KB Articles | X | X | 0 | 0 |
| Attachments | X | X | ≤Y | Document each skipped file (>20MB, expired URL) |
Field-level validation
Sample 5% of migrated records (minimum 50 records) and verify:
- Customer name and email match source
- Conversation status, priority, and assignee are correct
- Message body content is complete (not truncated to preview)
- Tags are correctly linked
- Internal notes remained internal — a failure here exposes agent chatter to clients
- Ticket Type Fields contain the expected KObject values
- Timestamps are correctly preserved (check timezone handling)
- Attachment files are accessible and not corrupt
UAT process
- Migrate a small batch (100–500 conversations) first
- Have 2–3 agents review their own historical conversations
- Verify search and filter behavior in Unthread's inbox
- Test automation rules that depend on migrated tags or ticket types
- Verify that migrated private notes are invisible to customer-facing channels
- Run a full migration only after UAT sign-off
Rollback planning
Unthread supports record deletion via API (DELETE /conversations/:id, DELETE /customers/:id). Deleting a conversation also deletes its child messages. Tag links are removed when conversations are deleted, but the tags themselves persist. If a migration batch fails validation, delete the batch and re-run. Keep your Kustomer instance active until validation is complete — do not cancel your subscription until all data is confirmed intact in Unthread.
Post-Migration Tasks
Re-enable and rebuild automations
- Re-enable Unthread automations that were disabled during import
- Rebuild Kustomer workflows as Unthread Automations. Unthread's Automation API supports event-based triggers, webhook triggers, scheduled triggers, and conditional branching. Map each Kustomer workflow to its Unthread equivalent — but expect gaps. Complex Kustomer workflows with multiple API calls may need simplification
Reconfigure routing and SLAs
Set up support steps on Unthread Customers to replicate Kustomer's routing queues. Configure assignment, escalation, and reminder steps with appropriate timeouts.
Update Slack Connect channels
Invite your B2B customers to the new Unthread-monitored Slack channels. Historical Kustomer conversations won't appear in these channels — they'll live as archived web tickets in Unthread's dashboard.
Train agents
Agents moving from Kustomer's full-screen CRM to Unthread's Slack-native interface need to adjust:
- Ticket management happens inside Slack, not a separate browser app
- Internal notes are Slack thread messages marked as private
- KObject data (if migrated) lives in Ticket Type Fields, not a sidebar panel
- Search syntax and filtering differ from Kustomer's saved searches
Knowledge base
If you use Kustomer's KB, select a destination. Unthread supports Knowledge Base articles via API, but if you need a public-facing help center, you may need an additional platform (Notion, Document360, or similar) since Unthread focuses on ticket resolution rather than hosting public help centers.
Archive Kustomer
Do not delete your Kustomer instance immediately. Downgrade to a single admin license and keep it in read-only mode for 90 days to satisfy compliance and audit requirements. This also provides a reference for any "where did X go?" questions during the transition. Monitor daily for the first week post-migration to catch missing history, orphaned Customers, or tags that didn't link correctly.
Best Practices
- Back up everything first. Export Kustomer data via API and store raw JSON files in cloud storage (S3, GCS) before any transformation. These raw files are your rollback safety net.
- Run at least two test migrations. The first reveals mapping bugs. The second confirms fixes. Never go live on the first attempt.
- Disable Unthread automations during import. Bulk record creation triggers webhooks, Slack notifications, and automation rules. Disable everything before import; re-enable after validation.
- Use metadata for traceability. Store the Kustomer source ID in every Unthread record's
metadatafield. If you ever need to cross-reference a legacy system, this ID is your only lifeline. - Automate validation. Write scripts that compare source and target record counts and flag mismatches. Manual spot-checks catch formatting issues; automated checks catch volume issues.
- Migrate during low-traffic hours. Running migrations off-peak reduces the chance of rate-limit conflicts with live agent activity on Kustomer.
- Document what you drop. Keep a record of every Kustomer object, field, or workflow you chose not to migrate, and why. This prevents "where did X go?" questions six months later.
- Communicate the freeze. Tell agents exactly when Kustomer becomes read-only. Any macro, tag, or ticket created after the freeze will not make it to Unthread.
- Validate incrementally. Don't wait until the entire migration is complete to check data. Validate as you go — it's cheaper to catch errors on batch 3 than on batch 300.
- Plan for attachment failures. Some attachments will fail (expired URLs, files >20MB, corrupt files). Log these separately and decide post-migration whether to manually retrieve them from Kustomer.
What This Migration Comes Down To
A Kustomer to Unthread migration is a data-model translation project. You're moving from a CRM-heavy, omnichannel platform built around the customer timeline to a Slack-native conversational helpdesk built around the ticket. The core data — customers, conversations, messages, tags — maps reasonably well. The hard parts are KObject data (which has no lossless target), full message extraction (which requires the API, not CSV exports), attachment URL expiry (which breaks naive scripts at the 15–60 minute window), workflow reconstruction (which is entirely manual), and automation disabling during import (which prevents notification storms).
For teams with straightforward Kustomer setups — conversations, customers, tags, a few KObject types — a well-written Python script and a week of focused work can handle the migration. At 50K+ conversations, budget 70–100+ hours of extraction time alone at Kustomer's default 100 RPM rate limit, plus transform and load time. For teams with complex KObject schemas, large volumes, or zero tolerance for data loss, a managed service eliminates the engineering risk.
Frequently Asked Questions
- Can I migrate full conversation history from Kustomer to Unthread?
- Yes, but only via the Kustomer REST API. CSV exports from Kustomer's reporting tools only include message previews, not full message bodies. You must use the /v1/conversations/{id}/messages endpoint to extract complete conversation history, then load it into Unthread using the POST /conversations/{id}/messages endpoint.
- Does Unthread support Kustomer's custom objects (KObjects)?
- Not directly. Unthread has no equivalent of Kustomer's freeform KObjects. The closest construct is Ticket Type Fields, which support basic types like short-answer, single-select, and multi-select. Complex or nested KObject schemas must be flattened (a lossy transformation), stored in an external CRM, or dropped.
- What is Kustomer's API rate limit for data migration?
- Kustomer's default machine-user API rate limit is 100 requests per minute. Enterprise plans get higher webhook limits (1,000 RPM), and Ultimate plans get 2,000 RPM. The Search API is also limited to 100 pages per query, requiring updated_at windowing for large datasets.
- Can I migrate historical Kustomer emails into Unthread Slack channels?
- No. Historical Kustomer conversations (email, chat, SMS) cannot be retroactively injected into live Slack Connect channels. They must be imported via Unthread's API as web-based historical tickets with type set to 'email' or 'triage'.
- How do I access Kustomer conversation data older than 2 years?
- Kustomer's standard Search API only returns records updated within the past 2 years. For older data, use Kustomer's Archive Search endpoint alongside the standard API to ensure you extract your full conversation history.


