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:
- Intercom AI seat at $79–$129 — boards push back past $20k/yr
- Want deep LLM customization that Fin doesn’t allow
- Compliance (GDPR / PIPL) requires self-host
Phases#
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#
| Intercom | Chatwoot | Notes |
|---|---|---|
conversation.id | source_id | Persist in additional_attributes |
conversation_parts | messages | Align content_type |
user.email | contact.email | Primary key |
user.custom_attributes | contact.additional_attributes | JSON |
tags | labels | Many-to-many |
teams | teams | Recreate manually |
macros | canned_responses | Same 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:
| Week | Split | Goal |
|---|---|---|
| 1 | Intercom 90% / Chatwoot 10% | Find Chatwoot config issues |
| 2 | 70% / 30% | Compare CSAT |
| 3 | 40% / 60% | Train agents |
| 4 | 0% / 100% | Cut DNS |
Splitter in the widget:
const useNew = parseInt(userId.slice(-2), 16) < 100 * pctNew;
if (useNew) loadChatwootWidget(); else loadIntercomWidget();
After cutover#
- Mark Intercom history as “migrated” and store a read-only snapshot
- Label every imported Chatwoot conversation
from_intercom - Decide retention after one year (compliance)
Three gotchas#
- Attachments expire — Intercom image URLs are signed and expire; download to S3 during export
- Timezones — Intercom is UTC; Chatwoot displays in account timezone; convert
- Email case — Intercom is case-sensitive; Chatwoot defaults lowercase; normalize on import