---
title: "Crisp to Intercom Migration: The Technical Guide"
slug: crisp-to-intercom-migration-the-technical-guide
date: 2026-07-23
author: Roopi
categories: [Migration Guide, Intercom]
excerpt: "Technical guide to migrating from Crisp to Intercom — API extraction, object mapping, rate limits, conversation history, and step-by-step implementation with code."
tldr: "Crisp to Intercom migration is API-driven. Conversations must be extracted via REST API, transformed from Crisp's session model to Intercom's contact-conversation schema, and loaded while managing rate limits on both sides."
canonical: https://clonepartner.com/blog/crisp-to-intercom-migration-the-technical-guide/
---

# Crisp to Intercom Migration: The Technical Guide


# Crisp to Intercom Migration: The Technical Guide

Migrating from Crisp to Intercom is an API-first project. Crisp can export contact profiles as CSV, but conversation data — messages, notes, attachments, routing context — is not available as a native CSV export from the dashboard. ([help.crisp.chat](https://help.crisp.chat/en/article/how-to-export-contact-profiles-g1h8jm/)) If historical chat transcripts matter to your support team, you're extracting from the Crisp REST API, transforming data to match Intercom's schema, and loading it while managing rate limits on both platforms.

This guide covers the full migration architecture: data model mapping, API constraints on both sides, step-by-step extraction and loading with code examples, edge cases, failure recovery, and validation strategies. It's written for engineering teams and IT leads who need to execute a Crisp-to-Intercom migration with zero data loss.

*All API references target Crisp REST API v1 and Intercom API version 2.11 unless otherwise noted. Verify endpoints against current documentation before implementation, as both platforms update their APIs periodically.*

## Why Companies Move from Crisp to Intercom

**Crisp** is a chat-first customer messaging platform popular with startups and SMBs. It bundles live chat, a basic CRM, knowledge base, and chatbot builder into a lightweight package. **Intercom** is a broader customer platform with deeper product analytics, marketing automation, an advanced ticketing system, and AI-powered resolution workflows.

The most common technical and operational triggers for this migration:

- **Outgrowing Crisp's CRM:** Crisp embeds company data directly on contact profiles rather than maintaining standalone company objects. Teams managing B2B accounts with multi-contact relationships hit this ceiling fast — there's no way to query "all contacts at Company X" without scanning every profile.
- **Advanced ticketing:** Crisp's ticketing system (available only on the Unlimited plan at €295/month per workspace) is relatively basic. Intercom's tickets have first-class API support, typed attributes, configurable state machines, and SLA tracking.
- **Marketing automation:** Intercom's Series (outbound campaigns), Product Tours, and Fin AI agent offer depth that Crisp's campaign tools don't match.
- **Integration ecosystem:** Intercom's marketplace and deep integrations with tools like Salesforce, HubSpot, and Stripe provide more flexibility at scale.
- **B2B account management:** Intercom's Company object enables support teams to manage SLAs and routing based on account tier — something Crisp cannot do natively.

## Core Data Model Differences: Crisp vs Intercom

Before writing a single line of migration code, understand where these two platforms diverge structurally. Crisp is built around the **Website** and the **Session** — a user visits a site, starts a chat, and Crisp logs a session. Intercom is built around the **Contact** and the **Company** — every interaction ties to a persistent user profile that tracks events, custom attributes, and conversation history across multiple channels. ([help.crisp.chat](https://help.crisp.chat/en/article/getting-started-with-custom-datas-43jgi3/))

| Crisp Concept | Intercom Equivalent | Notes |
|---|---|---|
| Contact (People) | Contact (User or Lead) | Intercom splits contacts into `user` (identified, has `external_id` or email) and `lead` (anonymous/visitor) roles |
| Company (embedded on contact) | Company (standalone object) | Crisp stores company as a property on contact; Intercom treats Company as a first-class entity with its own API and relationships |
| Conversation | Conversation or Ticket | Routing decision needed: which Crisp conversations become Intercom conversations vs. tickets? |
| Messages (in conversation) | Conversation Parts | Intercom limits to 500 parts per conversation |
| Segments | Tags | Crisp segments are tag-like labels; map directly to Intercom tags |
| Custom Data (contact-level) | Custom Data Attributes | Intercom requires attributes to be pre-defined via API before population |
| Custom Data (session-level) | Conversation custom attributes or note | No direct equivalent for per-session key-value pairs in standard conversations; ticket custom attributes available if importing as tickets |
| Notes (private) | Notes (on contact) or admin notes in conversation | Crisp private notes → Intercom conversation parts with type `note` |
| Events (page views, custom) | Events | Intercom supports custom events via `POST /events` but does not support backdating — events are recorded at submission time only |
| Knowledge Base articles | Articles (Help Center) | Intercom has an Articles API; Crisp articles need re-creation |
| Chatbot / Workflows | Custom Bots / Workflows | Must be rebuilt manually in Intercom's visual builder — no API import |

> [!WARNING]
> Crisp does not have standalone "Company" objects with their own API endpoints. Company data (name, URL, description, employment, geolocation) is stored as a property on contact profiles. During migration, you'll need to deduplicate and create these as standalone Intercom Company objects, then link contacts to them.

A key design decision before you start: should Crisp conversations become Intercom **conversations** or **tickets**? Use Intercom conversations when you want Messenger-style historical context. Use tickets when you need structured states, custom attributes, and queue-based routing — but create ticket types first via the API. ([intercom.com](https://www.intercom.com/help/en/articles/9396032-historical-data-migration-to-intercom))

## Migration Approaches

### 1. CSV Export + Manual Import

**How it works:** Export contacts from Crisp's dashboard (Contacts → Actions → Export Contact Profiles) as CSV. Clean the data, then import via Intercom's CSV tool or API.

**When to use it:** Contact-only migrations where you don't need conversation history.

**Pros:**
- No code required for the export step
- Fast for small contact lists (under 5,000 contacts)

**Cons:**
- Conversation data cannot be exported as CSV from Crisp
- Contact CSV export is limited to 200,000 contacts
- Intercom cannot import company data via CSV — companies must be created through the API ([intercom.com](https://www.intercom.com/help/en/articles/177-import-your-user-data-into-intercom))
- Intercom CSV files are capped at 20 MB per file
- No relationship preservation (company links, conversation history)

**Complexity:** Low | **Scalability:** Small datasets only

### 2. API-Based Migration (Recommended)

**How it works:** Use the Crisp REST API to programmatically extract contacts, conversations, messages, segments, and custom data. Transform the data to match Intercom's schema. Load via the Intercom REST API. ([docs.crisp.chat](https://docs.crisp.chat/references/rest-api/v1/))

**When to use it:** Any migration that includes conversation history, custom data, or more than a few hundred contacts.

**Pros:**
- Full data fidelity — conversations, messages, attachments, timestamps
- Preserves historical `created_at` timestamps on Intercom conversations
- Automatable and repeatable for test runs
- Handles custom data and segment-to-tag mapping

**Cons:**
- Requires engineering effort (Python/Node.js scripting, typically 1–3 weeks)
- Must manage rate limits on both APIs
- No bulk endpoints on either side — each record is an individual API call

Intercom's own migration guidance notes that replaying every historical message can increase API volume by 10–100×. In practice, many teams create a short seed conversation and post the legacy transcript as admin notes rather than replaying every individual message part. This approach can reduce both API calls and migration time by an order of magnitude. ([intercom.com](https://www.intercom.com/help/en/articles/9396032-historical-data-migration-to-intercom))

**Complexity:** High | **Scalability:** Enterprise-ready with proper batching

### 3. Middleware Platforms (Zapier, Make)

**How it works:** Use pre-built connectors to sync Crisp events to Intercom in real-time or trigger batch workflows.

**When to use it:** Ongoing sync of new conversations or contacts during a transition period — not historical data migration.

**Pros:**
- No custom code
- Good for maintaining parallel systems during transition

**Cons:**
- Cannot backfill historical conversations
- Limited field mapping flexibility
- Per-task pricing becomes expensive at scale (Zapier charges per task; at 1,000+ synced records/month, costs can exceed $100/month)

Zapier has a no-code Crisp + Intercom integration. Make's Crisp app exposes modules for creating conversations, profiles, and messages. These are useful for light synchronization after a bulk migration — not as the primary migration engine. ([zapier.com](https://zapier.com/apps/crisp/integrations/intercom))

**Complexity:** Low–Medium | **Scalability:** Low (cost-prohibitive above ~1,000 records/month)

### 4. Third-Party Migration Tools

**How it works:** Off-the-shelf SaaS tools map standard fields between the two platforms.

**When to use it:** Standard configurations with no custom fields or complex routing rules.

**Pros:**
- Faster start than custom code
- Lower engineering effort

**Cons:**
- Transform logic and object coverage vary by vendor
- Often fail on edge cases: custom data types, large attachments, company links
- Poor error logging and limited retry control

Crisp itself points users to third-party tooling for conversation export. ([help.crisp.chat](https://help.crisp.chat/en/article/how-to-export-contact-profiles-g1h8jm/))

**Complexity:** Medium | **Scalability:** Small to mid-market

### 5. Custom ETL Pipeline

**How it works:** Data is extracted from Crisp into staging storage (e.g., PostgreSQL or JSON files on S3), transformed through worker jobs with ID maps and checkpoints, and loaded into Intercom in ordered batches with delta reconciliation.

**When to use it:** Enterprise volume (50K+ conversations), compliance needs, parallel operation, delta sync, or strict rollback requirements.

**Pros:**
- Best observability, auditability, and control
- Can re-run specific batches without restarting the full pipeline
- Separates extraction failures from load-side failures
- Supports incremental delta sync for zero-downtime cutover

**Cons:**
- Highest build cost and longest setup (2–4 weeks typical)
- Overkill for migrations under 10K conversations

This pattern matches Intercom's recommended approach: mapping, batch testing, an initial run up to a delta date, and a final cutover with delta pass. ([intercom.com](https://www.intercom.com/help/en/articles/9396032-historical-data-migration-to-intercom))

**Complexity:** High | **Scalability:** Enterprise-grade

### Approach Comparison

| Criteria | CSV Export | API-Based | Middleware | Third-Party Tool | Custom ETL |
|---|---|---|---|---|---|
| Conversation history | ❌ | ✅ | ❌ | Partial | ✅ |
| Custom data | Partial | ✅ | Limited | Varies | ✅ |
| Segments → Tags | Manual | ✅ | Partial | Varies | ✅ |
| Attachments | ❌ | ✅ | ❌ | Varies | ✅ |
| Engineering effort | None | High | Low | Low | High |
| Timeline | Hours | 1–3 weeks | Days | Days | 2–4 weeks |
| Best for | Contact-only reset | Full migration, dev team available | Ongoing sync | Standard, simple setups | Enterprise cutover |

**Recommendation by scenario:**
- **Small business, <1K contacts, no history needed:** CSV export + Intercom import
- **Mid-market, conversation history matters:** API-based migration or managed service
- **Enterprise, 100K+ contacts:** Managed migration service with test runs
- **Ongoing sync during transition:** Middleware for new data, API or managed service for backfill

## Pre-Migration Planning

### Data Audit

Before extracting anything, inventory your Crisp workspace:

- **Contacts:** Total count, percentage with email addresses (contacts without email cannot be reliably matched or merged in Intercom)
- **Conversations:** Total count, date range, average messages per conversation (calculate expected API calls: conversations × 2 minimum)
- **Segments:** List all segments applied to contacts and conversations
- **Custom data keys:** Enumerate all contact-level and session-level custom data keys in use — note the data type of each (string, integer, boolean)
- **Company data:** How many contacts have company information set? How many unique company names exist after normalization?
- **Attachments:** Estimate total file volume (images, PDFs, documents in conversations) — these must be re-hosted
- **Knowledge base articles:** Count articles, categories, and languages
- **Chatbot flows / Workflows:** Document workflows that need to be rebuilt
- **Integrations:** List active plugins that will need Intercom equivalents

See our [Help Desk Data Migration Planning Guide](https://clonepartner.com/blog/blog/help-desk-data-migration-planning-guide/) for a broader framework.

### Estimating Migration Duration

Use this formula to estimate API call volume and duration:

```
Total API calls ≈ 
  (contact_count × 1)           # extract contacts
  + (conversation_count × 1)    # list conversations  
  + (conversation_count × avg_message_pages)  # fetch messages
  + (contact_count × 1)         # create contacts in Intercom
  + (unique_companies × 1)      # create companies
  + (contact_count × 1)         # link contacts to companies
  + (conversation_count × 1)    # create conversations
  + (total_messages × 1)        # add conversation parts
  + (segment_assignments × 1)   # apply tags
```

**Example:** 10,000 contacts, 25,000 conversations, 150,000 messages, 500 unique companies:
- Extraction from Crisp: ~50,000 API calls → at 10,000/day (Website Token), ~5 days; with elevated Plugin Token, potentially 1 day
- Loading to Intercom: ~200,000 API calls → at sustained ~1,000/minute (leaving headroom), ~3.5 hours
- **Total estimated time:** 2–6 days for extraction (depending on token type), 4–8 hours for loading, plus transformation and validation

### What to Leave Behind

Not everything should migrate:

- **Visitor sessions without messages:** Anonymous page views with no conversation content
- **Stale contacts:** Contacts with no activity in 12+ months — consider archiving rather than importing (reduces Intercom seat/contact costs)
- **Session-level custom data:** Ephemeral data (current page URL, form state) doesn't belong in Intercom's contact model
- **Bot interaction logs:** Crisp chatbot flow logs don't have a 1:1 target in Intercom
- **Test records and dead segments:** Clean these before migration, not after

For a framework on what data to carry forward, see [Help Desk Data Migration Playbook: What Data to Move and What to Leave Behind](https://clonepartner.com/blog/blog/help-desk-data-migration-playbook/).

### GDPR and Compliance Considerations

Migrating customer data between SaaS platforms has regulatory implications:

- **Data Processing Agreements (DPAs):** Ensure you have DPAs in place with both Crisp and Intercom. Both platforms offer DPAs — Intercom's is available at intercom.com/legal.
- **Data residency:** Crisp stores data in the EU (France). Intercom offers US, EU, and AU hosting regions. If you're subject to GDPR, confirm your Intercom workspace region matches your compliance requirements.
- **Lawful basis for transfer:** Under GDPR, the lawful basis for processing (consent, legitimate interest, contract) carries over to the new platform. You don't need new consent for the migration itself, but your privacy policy should name Intercom as a processor.
- **Right to deletion:** Any deletion requests received during the migration window must be honored in both systems. Build a process to check for pending deletion requests before and after migration.
- **Data minimization:** The migration is an opportunity to purge data you no longer need. Don't import data you wouldn't collect today.

### Migration Strategy

| Strategy | Description | Best For |
|---|---|---|
| **Big bang** | Migrate everything in a single cutover window | Small to mid-size datasets, <50K conversations |
| **Phased** | Migrate contacts first, then conversations, then knowledge base | Complex setups with multiple data types |
| **Incremental** | Migrate historical data, then sync delta changes daily until cutover | Teams that can't afford downtime |

For most Crisp-to-Intercom migrations, a **phased approach** works best: contacts and companies first (they're dependencies for conversations), then conversation history, then knowledge base content.

> [!TIP]
> Run at least one full test migration on a separate Intercom development workspace before touching production. Intercom provides free developer workspaces for this purpose. Disable or exempt automated workflows and outbound campaigns for API-created records during migration. ([intercom.com](https://www.intercom.com/help/en/articles/9396032-historical-data-migration-to-intercom))

## API Constraints Reference

### Crisp API

- **Authentication:** Two methods — Website Tokens (single workspace, 10,000 requests/day) and Plugin Tokens (multi-workspace, daily quota-based, higher limits available on request). Plugin Tokens require registering a plugin in the Crisp Marketplace.
- **Rate limiting:** Website tokens are limited to 10,000 requests per day. Plugin tokens use a daily quota system that can be increased by contacting Crisp support. Crisp returns both `429` and `420` status codes for rate limit violations — handle both in your retry logic. Rate limit reset timing is daily, not sliding window. ([docs.crisp.chat](https://docs.crisp.chat/guides/rest-api/rate-limits/))
- **Conversation listing:** Paginated via `GET /v1/website/{website_id}/conversations/{page_number}`. Returns conversation metadata (session_id, state, timestamps, participants), not full message bodies. Page size is fixed by the API (typically 20 conversations per page).
- **Message retrieval:** Requires a separate call per conversation: `GET /v1/website/{website_id}/conversation/{session_id}/messages`. Returns messages in reverse chronological order. Pagination is timestamp-based — use the `timestamp_before` parameter to fetch older messages in batches.
- **Contact listing:** `GET /v1/website/{website_id}/people/profiles/{page_number}` — paginated, returns profile data including custom data keys.
- **No bulk export API:** Every conversation and its messages require individual API calls.

> [!WARNING]
> With a Website Token's 10,000 requests/day limit, extracting 5,000 conversations (1 call to list + 1 call per conversation for messages) would take at minimum a full day, assuming no retries. For datasets exceeding 5,000 conversations, request a Plugin Token with elevated quotas from Crisp — the registration process typically takes 1–3 business days through the Crisp Marketplace.

### Intercom API

- **API version:** Use `Intercom-Version: 2.11` header (or the latest stable version). All endpoints below are consistent across 2.10+. Pin your version explicitly in the header to avoid breaking changes.
- **Rate limits:** 1,000 API calls per minute per app for most plans. Enterprise plans get higher limits. Rate limits are distributed across 10-second windows. Intercom returns `429` with a `X-RateLimit-Reset` header indicating retry timing.
- **Contact creation:** `POST /contacts` — one contact per call. No bulk creation endpoint exists.
- **Company creation:** `POST /companies` — supports upsert by `company_id`. If a company with the same `company_id` exists, it updates rather than creating a duplicate.
- **Conversation creation:** `POST /conversations` — accepts a `created_at` Unix timestamp (seconds, not milliseconds) field designed for importing historical conversations. The initial message body is plain text only — HTML is not supported on the creation call. ([developers.intercom.com](https://developers.intercom.com/docs/references/2.14/rest-api/api.intercom.io/models/create_conversation_request))
- **Conversation replies:** `POST /conversations/{id}/reply` — adds subsequent messages (conversation parts) after initial creation. Supports HTML in the body field.
- **Custom attributes:** Must be created via `POST /data_attributes` before they can be populated on contacts or companies. Each attribute requires specifying the model (`contact`, `company`, or `conversation`), name, and data type (`string`, `integer`, `float`, `boolean`, `date`). ([developers.intercom.com](https://developers.intercom.com/docs/references/2.7/rest-api/api.intercom.io/data-attributes/createdataattribute))
- **Conversation parts limit:** Maximum of 500 parts per conversation. ([developers.intercom.com](https://developers.intercom.com/docs/references/2.12/rest-api/api.intercom.io/conversations/conversation))
- **Attachment limit:** Up to 10 image URLs per message as attachments.
- **Search latency:** Freshly created contacts may not appear in search results for several seconds. Cache IDs locally rather than searching immediately after creation.

## Step-by-Step Migration Process

### Step 1: Prepare the Intercom Workspace

Before importing any data, configure the destination:

1. **Create custom data attributes** in Intercom for every custom data key you use in Crisp. Use `POST /data_attributes` with the correct `model` (contact, company, or conversation) and `data_type`. Mismatched types will cause silent failures or rejected payloads.
2. **Create ticket types** if you plan to import certain Crisp conversations as Intercom tickets. ([developers.intercom.com](https://developers.intercom.com/docs/guides/tickets/create-a-ticket))
3. **Set up tags** that correspond to your Crisp segments, plus a `source:crisp-migration` tag for all migrated records.
4. **Configure teams and inboxes** — these are UI-only configurations in Intercom.
5. **Disable automated outbound campaigns and workflows** during migration to avoid triggering on imported records. Intercom's own migration guide emphasizes this step. ([intercom.com](https://www.intercom.com/help/en/articles/9396032-historical-data-migration-to-intercom))
6. **Create a "Legacy Agent" admin account** for attributing messages from Crisp operators who no longer have active accounts.

For a full pre-migration setup reference, see the [Intercom Migration Checklist](https://clonepartner.com/blog/blog/intercom-migration-checklist/).

### Step 2: Extract Contacts from Crisp

Use the Crisp REST API to pull all contact profiles with their custom data:

```python
import requests
import time
import json
import os

CRISP_API_BASE = "https://api.crisp.chat/v1"
WEBSITE_ID = "your-website-id"
HEADERS = {
    "Authorization": "Basic <base64(identifier:key)>",
    "X-Crisp-Tier": "plugin"
}
CHECKPOINT_FILE = "contact_checkpoint.json"

def load_checkpoint(filename):
    if os.path.exists(filename):
        with open(filename) as f:
            return json.load(f)
    return {"last_page": 0, "contacts": []}

def save_checkpoint(filename, data):
    with open(filename, "w") as f:
        json.dump(data, f)

def extract_all_contacts():
    checkpoint = load_checkpoint(CHECKPOINT_FILE)
    contacts = checkpoint["contacts"]
    page = checkpoint["last_page"] + 1
    
    while True:
        resp = requests.get(
            f"{CRISP_API_BASE}/website/{WEBSITE_ID}/people/profiles/{page}",
            headers=HEADERS
        )
        if resp.status_code in (429, 420):
            wait_time = 60
            print(f"Rate limited. Waiting {wait_time}s...")
            time.sleep(wait_time)
            continue
        if resp.status_code != 200:
            print(f"Error {resp.status_code}: {resp.text}")
            break
            
        data = resp.json().get("data", [])
        if not data:
            break
        contacts.extend(data)
        save_checkpoint(CHECKPOINT_FILE, {"last_page": page, "contacts": contacts})
        page += 1
        time.sleep(0.5)  # respect rate limits
    
    return contacts
```

### Step 3: Extract Conversations and Messages

This is the most API-intensive step. Each conversation requires at least two calls — one to list, one (or more) to fetch messages:

```python
CONVO_CHECKPOINT = "conversation_checkpoint.json"

def extract_conversations_with_messages():
    checkpoint = load_checkpoint(CONVO_CHECKPOINT)
    conversations = checkpoint.get("conversations", [])
    extracted_ids = {c["session_id"] for c in conversations}
    page = checkpoint.get("last_page", 0) + 1
    
    while True:
        resp = requests.get(
            f"{CRISP_API_BASE}/website/{WEBSITE_ID}/conversations/{page}",
            headers=HEADERS
        )
        if resp.status_code in (429, 420):
            time.sleep(60)
            continue
        if resp.status_code != 200:
            print(f"Error listing conversations page {page}: {resp.status_code}")
            break
            
        convos = resp.json().get("data", [])
        if not convos:
            break
        for convo in convos:
            session_id = convo["session_id"]
            if session_id in extracted_ids:
                continue  # skip already-extracted
            messages = fetch_all_messages(session_id)
            convo["messages"] = messages
            conversations.append(convo)
            extracted_ids.add(session_id)
        
        save_checkpoint(CONVO_CHECKPOINT, {
            "last_page": page, 
            "conversations": conversations
        })
        page += 1
    return conversations

def fetch_all_messages(session_id, timestamp_before=None):
    """Fetch all messages for a single conversation, handling pagination."""
    all_messages = []
    while True:
        url = f"{CRISP_API_BASE}/website/{WEBSITE_ID}/conversation/{session_id}/messages"
        params = {}
        if timestamp_before:
            params["timestamp_before"] = timestamp_before
        
        resp = requests.get(url, headers=HEADERS, params=params)
        if resp.status_code in (429, 420):
            time.sleep(60)
            continue
        if resp.status_code != 200:
            print(f"Error fetching messages for {session_id}: {resp.status_code}")
            break
        
        messages = resp.json().get("data", [])
        if not messages:
            break
        all_messages.extend(messages)
        
        # Crisp returns messages in reverse chronological order
        # Use the oldest message's timestamp to paginate further back
        oldest_timestamp = min(m.get("timestamp", 0) for m in messages)
        if len(messages) < 20:  # less than a full page — we've reached the end
            break
        timestamp_before = oldest_timestamp
        time.sleep(0.5)
    
    return all_messages
```

> [!TIP]
> Save extracted data to local JSON files after each batch. If extraction fails mid-way (rate limit, network error), you can resume from the last saved checkpoint instead of restarting. Use Crisp message `fingerprint` fields and `timestamp` values as dedupe keys — they're more reliable than position in thread.

### Step 4: Transform Data

The transformation layer handles several tasks:

1. **Map Crisp contacts to Intercom contacts** — set `role` as `user` (if they have an `external_id` or email) or `lead` (if anonymous/visitor-only)
2. **Deduplicate and create Company objects** — extract unique company names from contact profiles, normalize casing and whitespace, prepare standalone Intercom Company records
3. **Map Crisp segments to Intercom tags**
4. **Convert Crisp custom data keys** to Intercom-compatible attribute names (lowercase, underscores instead of spaces, no special characters)
5. **Enforce data type consistency** — cast values to match the Intercom attribute type you pre-created (e.g., ensure integer fields don't contain strings)
6. **Map conversation messages** — determine `from` (user or admin), extract `content`, set `created_at` timestamps
7. **Convert timestamps** — Crisp returns timestamps in milliseconds; Intercom expects 10-digit Unix epoch in seconds. Divide by 1000 and floor.

```python
import re
from unicodedata import normalize

def sanitize_attribute_name(crisp_key):
    """Convert Crisp custom data key to Intercom-compatible attribute name."""
    name = normalize("NFKD", crisp_key).encode("ascii", "ignore").decode()
    name = re.sub(r"[^a-z0-9_]", "_", name.lower())
    name = re.sub(r"_+", "_", name).strip("_")
    return name

def transform_contact(crisp_contact):
    """Transform a Crisp contact into Intercom contact payload."""
    email = crisp_contact.get("email")
    has_identity = bool(email or crisp_contact.get("person", {}).get("nickname"))
    
    custom_attrs = {}
    for key, value in crisp_contact.get("data", {}).items():
        safe_key = sanitize_attribute_name(key)
        custom_attrs[safe_key] = value
    
    # Add geolocation as custom attributes
    geo = crisp_contact.get("person", {}).get("geolocation", {})
    if geo.get("country"):
        custom_attrs["country"] = geo["country"]
    if geo.get("city"):
        custom_attrs["city"] = geo["city"]
    
    return {
        "role": "user" if has_identity else "lead",
        "email": email,
        "name": crisp_contact.get("person", {}).get("nickname", ""),
        "phone": crisp_contact.get("person", {}).get("phone"),
        "custom_attributes": custom_attrs,
        "company_name": crisp_contact.get("company", {}).get("name"),
        "_crisp_people_id": crisp_contact.get("people_id")  # internal tracking
    }

def convert_timestamp(crisp_ts):
    """Convert Crisp millisecond timestamp to Intercom seconds."""
    if crisp_ts and crisp_ts > 9999999999:  # likely milliseconds
        return int(crisp_ts / 1000)
    return crisp_ts
```

> [!WARNING]
> Intercom enforces strict data typing for Custom Attributes (string, integer, float, boolean, date). If your Crisp `data` field contains mixed types — for example, passing the string `"42"` into an integer field, or `null` into a boolean — the Intercom API will reject the entire contact payload with a 400 error. Validate and cast all values before loading.

### Step 5: Load Contacts into Intercom

```python
INTERCOM_BASE = "https://api.intercom.io"
INTERCOM_HEADERS = {
    "Authorization": "Bearer <your-access-token>",
    "Content-Type": "application/json",
    "Intercom-Version": "2.11"
}

# Local cache: email/external_id → Intercom ID
contact_id_cache = {}

def create_intercom_contact(contact_data):
    payload = {
        "role": contact_data["role"],
        "custom_attributes": contact_data.get("custom_attributes", {})
    }
    if contact_data.get("email"):
        payload["email"] = contact_data["email"]
    if contact_data.get("name"):
        payload["name"] = contact_data["name"]
    if contact_data.get("phone"):
        payload["phone"] = contact_data["phone"]
    if contact_data.get("external_id"):
        payload["external_id"] = contact_data["external_id"]
    
    resp = requests.post(
        f"{INTERCOM_BASE}/contacts",
        headers=INTERCOM_HEADERS,
        json=payload
    )
    
    if resp.status_code == 200:
        result = resp.json()
        contact_id_cache[contact_data.get("email", "")] = result["id"]
        return result
    elif resp.status_code == 409:
        # Contact already exists — search and update instead
        return handle_duplicate(contact_data)
    elif resp.status_code == 429:
        retry_after = int(resp.headers.get("X-RateLimit-Reset", 10))
        time.sleep(retry_after)
        return create_intercom_contact(contact_data)  # retry
    else:
        log_failure("contact_create", contact_data, resp.status_code, resp.text)
        return None

def handle_duplicate(contact_data):
    """Search for existing contact by email and update."""
    search_resp = requests.post(
        f"{INTERCOM_BASE}/contacts/search",
        headers=INTERCOM_HEADERS,
        json={
            "query": {
                "field": "email",
                "operator": "=",
                "value": contact_data["email"]
            }
        }
    )
    if search_resp.status_code == 200:
        results = search_resp.json().get("data", [])
        if results:
            existing_id = results[0]["id"]
            contact_id_cache[contact_data["email"]] = existing_id
            # Update with custom attributes
            requests.put(
                f"{INTERCOM_BASE}/contacts/{existing_id}",
                headers=INTERCOM_HEADERS,
                json={"custom_attributes": contact_data.get("custom_attributes", {})}
            )
            return results[0]
    return None

def log_failure(operation, payload, status_code, error_text):
    """Log failed API calls for retry."""
    with open("migration_errors.jsonl", "a") as f:
        f.write(json.dumps({
            "operation": operation,
            "payload": payload,
            "status_code": status_code,
            "error": error_text,
            "timestamp": time.time()
        }) + "\n")
```

> [!NOTE]
> Intercom returns a 409 Conflict if you create a contact with an email or external_id that already exists. Build a lookup cache of `email → intercom_id` to avoid redundant creation attempts. Freshly created contacts may not be immediately searchable in Intercom (eventual consistency delay of a few seconds), so cache IDs locally rather than re-searching. ([developers.intercom.com](https://developers.intercom.com/docs/references/2.3/rest-api/contacts/search-for-contacts))

### Step 6: Create Companies and Link Contacts

```python
import re

def slugify(text):
    """Create a URL-safe company_id from company name."""
    text = text.lower().strip()
    text = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
    return text

def create_company_and_link(company_name, contact_intercom_id):
    if not company_name or not contact_intercom_id:
        return None
    
    company_id_slug = slugify(company_name)
    
    # Upsert company (Intercom creates or updates based on company_id)
    company_resp = requests.post(
        f"{INTERCOM_BASE}/companies",
        headers=INTERCOM_HEADERS,
        json={"company_id": company_id_slug, "name": company_name}
    )
    if company_resp.status_code not in (200, 201):
        log_failure("company_create", {"name": company_name}, 
                    company_resp.status_code, company_resp.text)
        return None
    
    company_intercom_id = company_resp.json()["id"]
    
    # Link contact to company
    link_resp = requests.post(
        f"{INTERCOM_BASE}/contacts/{contact_intercom_id}/companies",
        headers=INTERCOM_HEADERS,
        json={"id": company_intercom_id}
    )
    if link_resp.status_code not in (200, 201):
        log_failure("company_link", {
            "contact_id": contact_intercom_id,
            "company_id": company_intercom_id
        }, link_resp.status_code, link_resp.text)
    
    return company_intercom_id
```

Create and attach companies before loading conversations. Intercom companies don't appear in useful views until contacts are associated with them.

### Step 7: Import Conversations with History

Intercom's Create Conversation endpoint accepts a `created_at` timestamp specifically designed for importing historical conversations:

```python
def import_conversation(crisp_convo, contact_intercom_id, admin_id_map):
    """
    Import a Crisp conversation into Intercom.
    
    Args:
        crisp_convo: Crisp conversation dict with 'messages' key
        contact_intercom_id: Intercom ID of the conversation's contact
        admin_id_map: Dict mapping Crisp operator IDs to Intercom admin IDs
    """
    messages = crisp_convo.get("messages", [])
    if not messages:
        return None
    
    # Sort messages chronologically (Crisp returns reverse-chron)
    messages.sort(key=lambda m: m.get("timestamp", 0))
    first_msg = messages[0]
    
    # Strip HTML from first message — Intercom creation endpoint 
    # requires plain text for the initial body
    first_body = strip_html(first_msg.get("content", "(no content)"))
    
    # Create conversation with original timestamp
    convo_resp = requests.post(
        f"{INTERCOM_BASE}/conversations",
        headers=INTERCOM_HEADERS,
        json={
            "from": {"type": "user", "id": contact_intercom_id},
            "body": first_body,
            "created_at": convert_timestamp(first_msg.get("timestamp"))
        }
    )
    
    if convo_resp.status_code not in (200, 201):
        log_failure("conversation_create", {
            "session_id": crisp_convo.get("session_id"),
            "contact_id": contact_intercom_id
        }, convo_resp.status_code, convo_resp.text)
        return None
    
    convo_id = convo_resp.json().get("conversation_id")
    parts_added = 0
    
    # Add subsequent messages as replies (up to 499 more, since first msg is part 1)
    for msg in messages[1:500]:
        if parts_added >= 499:
            # Log truncation
            log_failure("conversation_truncated", {
                "session_id": crisp_convo.get("session_id"),
                "total_messages": len(messages),
                "imported": parts_added + 1
            }, 0, "Exceeded 500 part limit")
            break
        
        is_operator = msg.get("from") == "operator"
        
        # Handle private notes
        if msg.get("type") == "note":
            reply_payload = {
                "message_type": "note",
                "type": "admin",
                "admin_id": admin_id_map.get(
                    msg.get("user", {}).get("user_id"), 
                    admin_id_map["_legacy"]
                ),
                "body": msg.get("content", "")
            }
        elif is_operator:
            reply_payload = {
                "message_type": "comment",
                "type": "admin",
                "admin_id": admin_id_map.get(
                    msg.get("user", {}).get("user_id"),
                    admin_id_map["_legacy"]
                ),
                "body": msg.get("content", "")
            }
        else:
            reply_payload = {
                "message_type": "comment",
                "type": "user",
                "intercom_user_id": contact_intercom_id,
                "body": msg.get("content", "")
            }
        
        reply_resp = requests.post(
            f"{INTERCOM_BASE}/conversations/{convo_id}/reply",
            headers=INTERCOM_HEADERS,
            json=reply_payload
        )
        
        if reply_resp.status_code == 429:
            retry_after = int(reply_resp.headers.get("X-RateLimit-Reset", 10))
            time.sleep(retry_after)
            # Retry this message
            requests.post(
                f"{INTERCOM_BASE}/conversations/{convo_id}/reply",
                headers=INTERCOM_HEADERS,
                json=reply_payload
            )
        
        parts_added += 1
        time.sleep(0.1)  # gentle throttle
    
    return convo_id

def strip_html(text):
    """Basic HTML tag removal for plain text conversion."""
    return re.sub(r"<[^>]+>", "", text or "").strip()
```

> [!WARNING]
> Intercom limits conversations to 500 parts. If a Crisp conversation exceeds this (possible with long-running chat sessions), you have three options: (1) truncate older messages and log what was dropped, (2) split into multiple Intercom conversations linked by a custom attribute, or (3) archive the full transcript as an attached file or external link and import a summary as the conversation body.

### Step 8: Apply Tags

```python
def apply_tags(intercom_convo_id, segment_list):
    for segment in segment_list:
        resp = requests.post(
            f"{INTERCOM_BASE}/tags",
            headers=INTERCOM_HEADERS,
            json={
                "name": segment,
                "conversations": [{"id": intercom_convo_id}]
            }
        )
        if resp.status_code not in (200, 201):
            log_failure("tag_apply", {
                "conversation_id": intercom_convo_id,
                "tag": segment
            }, resp.status_code, resp.text)

    # Always apply migration source tag
    requests.post(
        f"{INTERCOM_BASE}/tags",
        headers=INTERCOM_HEADERS,
        json={
            "name": "source:crisp-migration",
            "conversations": [{"id": intercom_convo_id}]
        }
    )
```

Tag all migrated records with `source:crisp-migration` for easy filtering and potential rollback.

## Sample Data Mapping Table

| Crisp Field | Intercom Field | Type | Transformation |
|---|---|---|---|
| `people.email` | `contacts.email` | String | Direct — primary identifier for matching |
| `people.person.nickname` | `contacts.name` | String | Direct (split into first/last if needed via space delimiter) |
| `people.person.avatar` | `contacts.avatar` | URL | Direct |
| `people.person.phone` | `contacts.phone` | String | Normalize to E.164 format with country code |
| `people.person.geolocation.country` | `contacts.custom_attributes.country` | String | Custom attribute — must be pre-created |
| `people.person.geolocation.city` | `contacts.custom_attributes.city` | String | Custom attribute — must be pre-created |
| `people.company.name` | `companies.name` | String | Deduplicate, normalize casing → create Company object |
| `people.company.url` | `companies.website` | URL | Direct |
| `people.segments []` | `tags []` | Array→Tags | Create tag if not exists, apply to contact |
| `people.data.*` | `contacts.custom_attributes.*` | Key-Value | Pre-create attribute with correct type; sanitize key names |
| `conversation.session_id` | N/A (internal) | String | Use as idempotency key in migration log |
| `conversation.meta.subject` | `conversations.body` (first line) | String | Prepend to first message body |
| `conversation.state` | `conversations.state` | Enum | Map: `resolved`→`closed`, `pending`→`open`, `unresolved`→`open` |
| `message.content` | `conversation_parts.body` | String/HTML | Strip HTML for first message (plain text required); HTML allowed in replies |
| `message.from` | `conversation_parts.author` | Enum | `operator`→`admin`, `user`→`user` |
| `message.timestamp` | `conversation_parts.created_at` | Unix timestamp | Divide by 1000 if >10 digits (ms→seconds) |
| `message.type: file` | Attachment URL | URL | Re-host — Crisp CDN URLs (`storage.crisp.chat`) expire after subscription cancellation |
| `message.type: note` | Conversation part (type: note) | String | Create as admin note with `message_type: "note"` |

## Failure Mode Taxonomy

Understanding failure types helps you build appropriate retry and recovery logic:

| Failure Type | Category | Cause | Recovery Strategy |
|---|---|---|---|
| `429`/`420` from Crisp | Transient | Rate limit exceeded | Exponential backoff, resume from checkpoint |
| `429` from Intercom | Transient | Rate limit exceeded | Wait for `X-RateLimit-Reset`, retry |
| Network timeout | Transient | Connection issue | Retry with backoff, max 3 attempts |
| `409` from Intercom | Expected | Duplicate contact | Search existing, update, cache ID |
| `400` from Intercom | Permanent (data) | Invalid payload (type mismatch, missing required field) | Log, fix data transformation, retry batch |
| `422` from Intercom | Permanent (data) | Unprocessable entity (invalid attribute name, value out of range) | Log, sanitize data, retry |
| `404` from Crisp | Permanent | Conversation/contact deleted during extraction | Log and skip |
| `401`/`403` | Permanent (config) | Invalid or expired API token | Stop migration, refresh credentials |
| Conversation >500 parts | Permanent (limit) | Intercom hard limit | Truncate, split, or archive externally |
| Attachment download failure | Transient/Permanent | Crisp CDN issue or file deleted | Retry once; if permanent, log missing attachment and continue |

**Key principle:** Transient failures should be retried automatically with backoff. Permanent data failures should be logged with full context (session_id, people_id, payload, error response) to a `migration_errors.jsonl` file for manual review and batch retry after fixing transformations.

## Edge Cases and Challenges

### Duplicate Contacts

Crisp identifies contacts primarily by `people_id` (UUID). If the same person initiated multiple chat sessions before identifying themselves, they may exist as multiple contact profiles. Deduplicate by email before loading into Intercom. Intercom's matching is identifier-sensitive — inconsistent `user_id` and email usage is a common duplicate source. Build a pre-processing step that groups Crisp contacts by email and merges their custom data and conversation references. ([intercom.com](https://www.intercom.com/help/en/articles/177-import-your-user-data-into-intercom))

### Anonymous Users and Conversations Without Email

Crisp allows chats without an email. In Intercom, these become Leads, but you lose the ability to merge them later unless you have an `external_id`. Decide before migration whether to import anonymous conversations or skip them. Importing them creates Lead records that may generate thousands of unidentified entries. If you import, tag them distinctly (`source:crisp-anonymous`) so they can be bulk-managed later.

### Attachment Re-hosting

Crisp hosts attachments on its own CDN (`storage.crisp.chat`). These URLs become inaccessible after you cancel your Crisp subscription. Your migration script must:

1. Download files from Crisp during extraction
2. Upload them to a durable location (S3, GCS, or your own CDN)
3. Rewrite the URLs in message bodies before loading into Intercom

Intercom limits attachment URLs to 10 per create/reply payload. For threads with heavy file sharing, split attachments across multiple admin notes or archive older binaries externally with a link in the conversation.

### Agent Attribution

If an agent no longer works at your company, Intercom will not let you attribute a message to them unless their account exists (even if deactivated — deactivated admins can still be referenced by ID). Map inactive Crisp operators to a generic "Legacy Agent" admin account in Intercom. Store the original operator name in the message body prefix (e.g., `[Originally from: Jane Smith]`) so the attribution is preserved in the text even if the admin mapping is generic.

### Multi-Channel Conversations

Crisp conversations can span chat, email, Messenger, WhatsApp, and Telegram within a single session. Intercom conversations are created with a single source channel. For multi-channel Crisp threads, use the channel of the first message as the Intercom conversation source. If channel fidelity matters, add a custom attribute or note indicating the original channels involved.

### Session-Level Custom Data

Crisp stores two levels of custom data: contact-level and session/conversation-level. Intercom doesn't have a per-conversation custom data store for standard conversations (tickets do support custom attributes). Your options:
1. Flatten session data into a note on the conversation
2. Migrate it as contact-level attributes if it's persistent and contact-relevant
3. Use ticket custom attributes if you're importing as tickets
4. Archive it in your staging database for reference without importing

### Rate Limit Handling

Build exponential backoff into both extraction and loading scripts. Monitor the `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers from Intercom and the HTTP 429/420 status from Crisp. Log every failed request with enough context (session_id, people_id, full payload) to retry individually without re-running the entire migration. ([docs.crisp.chat](https://docs.crisp.chat/guides/rest-api/rate-limits/))

```python
def api_call_with_backoff(method, url, headers, json_payload=None, max_retries=5):
    """Generic API caller with exponential backoff for rate limits."""
    for attempt in range(max_retries):
        resp = method(url, headers=headers, json=json_payload)
        if resp.status_code in (429, 420):
            wait = min(2 ** attempt * 5, 120)  # 5s, 10s, 20s, 40s, 80s
            reset_header = resp.headers.get("X-RateLimit-Reset")
            if reset_header:
                wait = max(int(reset_header), wait)
            print(f"Rate limited. Waiting {wait}s (attempt {attempt + 1})")
            time.sleep(wait)
            continue
        return resp
    return resp  # return last response if all retries exhausted
```

### Workflow Side Effects

API-created records in Intercom can trigger automated workflows, assignment rules, and outbound campaigns unless you explicitly disable or exempt them during migration. This can burn through rate limits and send unwanted messages to customers. Intercom recommends pausing all automation during migration. ([intercom.com](https://www.intercom.com/help/en/articles/9396032-historical-data-migration-to-intercom))

**Specific risks:**
- Welcome messages sent to imported contacts
- Auto-assignment rules routing historical conversations to agents
- SLA timers starting on closed conversations
- Outbound series enrolling migrated contacts

### Knowledge Base Articles

Crisp's knowledge base articles need to be recreated in Intercom's Help Center. Intercom provides an Articles API (`POST /articles`) that accepts HTML body content. Extract articles from Crisp (via API at `GET /v1/website/{website_id}/helpdesk/locale/{locale}/articles`), convert formatting, and map categories to Intercom collections. Create collections first, then articles within them.

## Limitations and Constraints

| Constraint | Detail |
|---|---|
| Crisp conversation CSV export | Not available — API extraction only |
| Crisp contact CSV export limit | 200,000 contacts max per export |
| Crisp Website Token daily limit | 10,000 requests/day (fixed, not sliding window) |
| Crisp Plugin Token | Higher limits, requires Marketplace registration (1–3 day approval) |
| Intercom bulk contact import | No bulk endpoint — one `POST /contacts` per contact |
| Intercom conversation parts | Max 500 per conversation (hard limit) |
| Intercom conversation creation body | Plain text only — no HTML on initial `POST /conversations` |
| Intercom attachments per message | Max 10 image URLs per reply |
| Intercom custom attributes | Must be pre-created via `POST /data_attributes` before populating |
| Intercom events | Cannot be backdated — recorded at submission time |
| Saved replies / Macros | No API import path in Intercom — rebuild manually |
| Workflows / Bots | UI-only configuration in Intercom — cannot be migrated via API |
| CSAT / Satisfaction data | Historical CSAT responses are not importable into Intercom |
| SLA policy history | Not transferable between platforms |
| Knowledge base articles | Must be recreated via Intercom's Articles API or manually |
| Intercom migration support | Intercom offers migration assistance for larger accounts — contact your account manager if migrating 100K+ contacts |

## Validation and Testing

### Test Migration

Always run at least one full test migration on a separate Intercom development workspace before touching production:

- Use Intercom's free developer workspace (available at app.intercom.com/a/developer-signup)
- Migrate a representative subset: 100 contacts, 200 conversations with varied characteristics (multi-message, attachments, anonymous users, operator notes)
- Validate data integrity across all object types
- Measure throughput to estimate full migration duration — record actual API calls per minute achieved
- Identify edge cases unique to your data (unusual custom data types, very long conversations, special characters)

### Pre-Go-Live Validation Checklist

1. **Record count comparison:** Total contacts in Crisp vs. Intercom. Total conversations. Total messages (conversation parts). Discrepancies indicate dropped records — check error logs.
2. **Field-level spot checks:** Sample 50 contacts — verify email, name, custom attributes, company association, and tags match Crisp source data.
3. **Conversation integrity:** Sample 20 conversations (including edge cases) — verify message order, timestamps, sender attribution (admin vs. user), and attachment accessibility (click every attachment URL).
4. **Segment-to-tag mapping:** Confirm every Crisp segment appears as an Intercom tag with the correct number of associations.
5. **Company linkage:** Verify contacts are linked to the correct Intercom Company objects. Spot-check 10 companies with multiple contacts.
6. **Timestamp accuracy:** Confirm `created_at` on migrated conversations matches original Crisp timestamps (not the migration run date).
7. **Private notes:** Verify Crisp operator notes appear as internal notes in Intercom conversations, not customer-visible messages.
8. **Business UAT:** Have your support team search for 5 known complex tickets and confirm the history reads naturally and is operationally useful.

### Rollback Planning

Intercom does not offer a one-click rollback. Your plan should include:

- **Pre-migration backup:** Export all Crisp data to JSON files before starting. Store alongside your ID mapping tables.
- **Intercom cleanup scripts:** Build a script that deletes migrated contacts and conversations by tag — this is why you tag all migrated records with `source:crisp-migration` during import. Test the cleanup script on your dev workspace.
- **Parallel operation:** Keep Crisp active during migration and validation — don't cancel your Crisp subscription until post-migration UAT is complete and signed off.
- **Migration log:** Maintain a complete mapping of `crisp_session_id → intercom_conversation_id` and `crisp_people_id → intercom_contact_id` for audit and rollback purposes.

For a detailed post-migration testing framework, see [Post-Migration QA: 20 Tests to Run After Your Help Desk Data Migration](https://clonepartner.com/blog/blog/help-desk-data-migration-qa-checklist/).

## Post-Migration Tasks

### Rebuild in Intercom (Cannot Be Migrated via API)

- **Workflows and automations:** Recreate Crisp chatbot flows using Intercom's Custom Bots or Workflow builder
- **Saved replies / Shortcuts:** Manually recreate in Intercom's Macros
- **Assignment rules:** Configure in Intercom's Rules engine
- **SLA policies:** Set up from scratch in Intercom
- **Outbound campaigns:** Rebuild using Intercom Series
- **Fin AI:** If using Intercom's AI agent, point it at your newly imported Help Center articles and allow indexing time before enabling

### Agent Onboarding

- Train agents on Intercom's inbox interface, keyboard shortcuts, and conversation workflow — it behaves differently than Crisp's sidebar-based UI
- Verify team and role assignments match your Crisp operator structure
- Test routing rules with sample conversations before going live

### Monitoring (First 2 Weeks Post-Migration)

- Watch for duplicate contacts appearing post-migration (customers creating new conversations that don't match existing records)
- Monitor for broken attachment URLs if Crisp CDN files were not fully re-hosted
- Track API error logs (`migration_errors.jsonl`) for any conversations that failed to import — retry these individually
- If Crisp stays live during cutover, maintain a short-lived delta sync (new conversations created in Crisp after migration start) until you freeze the source
- Monitor Intercom's unassigned conversation queue for any migrated conversations that triggered assignment rules

## Best Practices

1. **Back up everything first.** Export all Crisp data to JSON before making any changes. Store backups in version-controlled or immutable storage.
2. **Run at least two test migrations** on a development workspace before production. The first identifies issues; the second validates fixes.
3. **Pre-create all Intercom custom attributes** before loading any contact data. Document the mapping table and share it with stakeholders.
4. **Tag migrated records** (`source:crisp-migration`) for easy filtering and potential rollback.
5. **Validate incrementally** — don't wait until the full migration is complete to start checking data. Validate after each phase (contacts, companies, conversations).
6. **Keep Crisp active** until post-migration UAT is fully signed off. Plan for 2–4 weeks of overlap.
7. **Automate idempotently.** Use Crisp `session_id` or `people_id` as external references so re-running the script doesn't create duplicates. Maintain a mapping file that persists across runs.
8. **Halt the Crisp widget during final cutover** to prevent new data from entering the source system.
9. **Log everything.** Maintain a verbose log of every failed API call, the associated payload, status code, and error response for debugging.
10. **Consider transcript notes over message replay** unless you genuinely need message-level fidelity. Posting the full conversation as a single admin note rather than replaying each message can reduce API calls by 10–100× and migration time proportionally.
11. **Pin your Intercom API version** explicitly in request headers to avoid breaking changes from API updates during migration.
12. **Re-host attachments before canceling Crisp.** Crisp CDN URLs at `storage.crisp.chat` become inaccessible after subscription ends.

## When to Use a Managed Migration Service

Building a Crisp-to-Intercom migration script is straightforward for small datasets. Complexity scales non-linearly:

- **10K+ conversations** with attachments require careful batching, retry logic, checkpoint recovery, and progress tracking
- **Deduplication** across contacts, companies, and conversations demands data reconciliation logic that's dataset-specific
- **Edge cases** — anonymous visitors, multi-channel threads, conversations exceeding 500 parts, mixed-type custom data — don't surface until mid-migration
- **Timestamp preservation** on Intercom conversations requires testing against your specific Intercom plan (behavior can vary)
- **Workflow side effects** from API-created records can trigger unwanted customer-facing messages if not properly suppressed
- **Attachment re-hosting** adds significant infrastructure and storage requirements

The hidden cost of DIY isn't the initial script — it's the engineering hours spent debugging edge cases, re-running failed batches, and validating data integrity across two platforms with different schemas and eventual consistency behaviors.

At [ClonePartner](https://www.clonepartner.com), we write custom migration scripts tailored to your data shape — not generic adapters. We handle relationship rebuilding, attachment re-hosting, rate limit management, and provide full validation reports with record-level reconciliation. If you'd rather your engineers ship product features instead of debugging migration scripts, [here's how we run migrations](https://clonepartner.com/blog/blog/how-we-run-migrations-at-clonepartner/).

> Planning a Crisp to Intercom migration? Our engineers have done this before. Book a free 30-minute call and we'll map out your migration plan — scope, timeline, and edge cases — with zero obligation.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Can I export conversation history from Crisp as CSV?

No. Crisp only supports CSV export for contact profiles (limited to 200,000 contacts). Conversation data, including messages and attachments, can only be extracted through the Crisp REST API.

### Does Intercom support importing historical conversation timestamps?

Yes. Intercom's Create Conversation API endpoint accepts a created_at field as a UTC Unix timestamp, specifically designed for migrating past conversations from another source.

### Should Crisp chats become Intercom conversations or tickets?

Use Intercom conversations when you want Messenger-style historical context. Use tickets when you need structured states, typed attributes, and queue-based routing. Create ticket types in Intercom before importing.

### How long does a Crisp to Intercom migration take?

It depends on data volume. A small migration (under 5,000 conversations) can be completed in 2–5 days. Larger datasets (50K+ conversations) typically take 1–3 weeks including test runs, transformation, and validation. With a managed service, the data cutover itself takes hours to a few days.

### What Crisp data cannot be migrated to Intercom?

Chatbot flows and workflows must be rebuilt manually in Intercom. CSAT/satisfaction survey responses, SLA policy history, and saved replies (macros) cannot be imported via API. Agent performance metrics are also not transferable.
