flag92 flag92
Blog
Published Sun Feb 01 2026 08:00:00 GMT+0800 (中国标准时间)
migrationChatwootIntercom

Migrating from Intercom to Chatwoot — the complete path

Data export, field mapping, parallel running, cutover — a battle-tested playbook with scripts and traps.

Why migrate#

Three common reasons:

  1. Intercom AI seat at $79–$129 — boards push back past $20k/yr
  2. Want deep LLM customization that Fin doesn’t allow
  3. Compliance (GDPR / PIPL) requires self-host

Phases#

Export
1-3 days

Mapping
3-5 days

Parallel
2-4 weeks

Cutover
1 day

Tail
1 week

Intercom Export API
conversations

Contacts / companies / tags
custom fields

Help Center articles

Field mapping script

Tags → custom attributes

Macros → canned responses

Both running
10% → 50% → 90%

Compare CSAT / FRT / deflection

Switch widget DNS

Bulk import + reconciliation

Export#

1. Conversations#

curl https://api.intercom.io/conversations \
  -H "Authorization: Bearer $INTERCOM_TOKEN" \
  -H "Accept: application/json" \
  > conversations.json

Cursor pagination — use the SDK:

from python_intercom import Client
intercom = Client(personal_access_token=TOKEN)
all_convs = []
for c in intercom.conversations.find_all():
    all_convs.append(c.to_dict())

2. Contacts / companies / tags#

Loop the per-resource endpoints, store as JSONL.

3. Help Center#

/articles returns Markdown — save directly.

Field mapping#

IntercomChatwootNotes
conversation.idsource_idPersist in additional_attributes
conversation_partsmessagesAlign content_type
user.emailcontact.emailPrimary key
user.custom_attributescontact.additional_attributesJSON
tagslabelsMany-to-many
teamsteamsRecreate manually
macroscanned_responsesSame text

Import into Chatwoot#

Chatwoot’s import API isn’t as polished — write a script:

import requests
CW = 'https://support.example.com/api/v1/accounts/1'
H = {'api_access_token': CW_KEY}

def import_contact(c):
    r = requests.post(f'{CW}/contacts', json={
        'name': c['name'],
        'email': c['email'],
        'additional_attributes': c['custom_attributes'],
    }, headers=H)
    return r.json()['payload']['contact']['id']

def import_conversation(conv, contact_id):
    cw_conv = requests.post(f'{CW}/conversations', json={
        'source_id': conv['id'],
        'inbox_id': 1,
        'contact_id': contact_id,
        'status': 'open' if conv['state'] == 'open' else 'resolved',
    }, headers=H).json()
    for part in conv['conversation_parts']['conversation_parts']:
        requests.post(f'{CW}/conversations/{cw_conv["id"]}/messages', json={
            'content': part['body'],
            'message_type': 'incoming' if part['author']['type'] == 'user' else 'outgoing',
        }, headers=H)

For > 100k conversations, batch + throttle or Chatwoot’s Postgres will choke.

Parallel rollout#

Don’t cut over directly. Four-week split:

WeekSplitGoal
1Intercom 90% / Chatwoot 10%Find Chatwoot config issues
270% / 30%Compare CSAT
340% / 60%Train agents
40% / 100%Cut DNS

Splitter in the widget:

const useNew = parseInt(userId.slice(-2), 16) < 100 * pctNew;
if (useNew) loadChatwootWidget(); else loadIntercomWidget();

After cutover#

  1. Mark Intercom history as “migrated” and store a read-only snapshot
  2. Label every imported Chatwoot conversation from_intercom
  3. Decide retention after one year (compliance)

Three gotchas#

  1. Attachments expire — Intercom image URLs are signed and expire; download to S3 during export
  2. Timezones — Intercom is UTC; Chatwoot displays in account timezone; convert
  3. Email case — Intercom is case-sensitive; Chatwoot defaults lowercase; normalize on import

Search

Press ⌘ K to open