Skills Development Intercom Data Migration Deep Dive

Intercom Data Migration Deep Dive

v20260423
intercom-migration-deep-dive
A comprehensive technical guide for executing major data migrations and re-platforming to Intercom. It covers bulk contact imports, syncing conversation histories, migrating company data, and transferring help center articles from source systems like Zendesk or Freshdesk.
Get Skill
154 downloads
Overview

Intercom Migration Deep Dive

Overview

Comprehensive guide for migrating to Intercom from other platforms (Zendesk, Freshdesk, HelpScout) or bulk-importing data. Covers contact import, conversation history, Help Center articles, tags, and companies.

Prerequisites

  • Intercom workspace with access token
  • Source system data exported (CSV or API access)
  • Feature flag infrastructure for gradual cutover
  • Rollback strategy tested

Migration Types

TypeComplexityDurationRisk
Contact importLowHoursLow
Zendesk/Freshdesk migrationMedium1-2 weeksMedium
Full re-platform (with history)High2-4 weeksHigh
Help Center migrationMediumDaysLow

Instructions

Step 1: Contact Import

import { IntercomClient, IntercomError } from "intercom-client";

const client = new IntercomClient({
  token: process.env.INTERCOM_ACCESS_TOKEN!,
});

interface SourceContact {
  id: string;
  email: string;
  name: string;
  phone?: string;
  plan?: string;
  company?: string;
  created_at: string;
  custom_fields?: Record<string, any>;
}

async function importContacts(
  contacts: SourceContact[]
): Promise<{ created: number; updated: number; failed: number; errors: any[] }> {
  const stats = { created: 0, updated: 0, failed: 0, errors: [] as any[] };

  for (const contact of contacts) {
    try {
      // Search for existing contact by external_id or email
      const existing = await client.contacts.search({
        query: {
          operator: "OR",
          value: [
            { field: "external_id", operator: "=", value: contact.id },
            { field: "email", operator: "=", value: contact.email },
          ],
        },
      });

      if (existing.data.length > 0) {
        // Update existing contact
        await client.contacts.update({
          contactId: existing.data[0].id,
          name: contact.name,
          phone: contact.phone,
          customAttributes: {
            ...contact.custom_fields,
            plan: contact.plan,
            migrated_from: "source_system",
            migration_date: new Date().toISOString(),
          },
        });
        stats.updated++;
      } else {
        // Create new contact
        await client.contacts.create({
          role: "user",
          externalId: contact.id,
          email: contact.email,
          name: contact.name,
          phone: contact.phone,
          signedUpAt: Math.floor(new Date(contact.created_at).getTime() / 1000),
          customAttributes: {
            ...contact.custom_fields,
            plan: contact.plan,
            migrated_from: "source_system",
            migration_date: new Date().toISOString(),
          },
        });
        stats.created++;
      }

      // Rate limit: pause every 50 contacts
      if ((stats.created + stats.updated) % 50 === 0) {
        console.log(`Progress: ${stats.created} created, ${stats.updated} updated`);
        await new Promise(r => setTimeout(r, 500));
      }
    } catch (err) {
      stats.failed++;
      stats.errors.push({
        contact_id: contact.id,
        email: contact.email,
        error: err instanceof IntercomError
          ? `${err.statusCode}: ${err.message}`
          : (err as Error).message,
      });
    }
  }

  return stats;
}

Step 2: Company Import

async function importCompanies(
  companies: Array<{ id: string; name: string; plan?: string; size?: number }>
): Promise<void> {
  for (const company of companies) {
    await client.companies.create({
      companyId: company.id,
      name: company.name,
      plan: company.plan,
      size: company.size,
      customAttributes: {
        migrated_from: "source_system",
      },
    });

    await new Promise(r => setTimeout(r, 100)); // Rate limit
  }
}

// Attach contacts to companies
async function attachContactToCompany(
  contactId: string,
  companyId: string
): Promise<void> {
  await client.contacts.attachCompany({
    contactId,
    companyId,
  });
}

Step 3: Tag Migration

async function migrateTags(
  tagMappings: Array<{ sourceName: string; contactIds: string[] }>
): Promise<void> {
  for (const mapping of tagMappings) {
    // Create tag if it doesn't exist
    const tag = await client.tags.create({ name: mapping.sourceName });

    // Apply tag to contacts
    for (const contactId of mapping.contactIds) {
      try {
        await client.contacts.tag({ contactId, id: tag.id });
      } catch (err) {
        if (err instanceof IntercomError && err.statusCode === 404) {
          console.warn(`Contact ${contactId} not found, skipping tag`);
          continue;
        }
        throw err;
      }
    }

    console.log(`Tagged ${mapping.contactIds.length} contacts with "${mapping.sourceName}"`);
  }
}

Step 4: Help Center Article Migration

async function migrateArticles(
  articles: Array<{
    title: string;
    body: string;     // HTML content
    category: string;
    state: "published" | "draft";
  }>,
  authorId: string   // Admin ID who will be the author
): Promise<void> {
  // Create or find collections for categories
  const collections = new Map<string, string>();

  for (const article of articles) {
    // Create collection if needed
    if (!collections.has(article.category)) {
      const collection = await client.helpCenter.createCollection({
        name: article.category,
      });
      collections.set(article.category, collection.id);
    }

    // Create article in collection
    await client.articles.create({
      title: article.title,
      body: article.body,
      authorId,
      parentId: collections.get(article.category),
      state: article.state,
    });

    console.log(`Migrated article: ${article.title}`);
    await new Promise(r => setTimeout(r, 200)); // Rate limit
  }
}

Step 5: Migration Orchestrator

interface MigrationPlan {
  contacts: SourceContact[];
  companies: Array<{ id: string; name: string; plan?: string }>;
  tags: Array<{ sourceName: string; contactIds: string[] }>;
  articles: Array<{ title: string; body: string; category: string; state: "published" | "draft" }>;
}

async function executeMigration(plan: MigrationPlan): Promise<void> {
  console.log("=== Starting Intercom Migration ===");
  const startTime = Date.now();

  // Phase 1: Companies (contacts reference these)
  console.log(`\n[Phase 1] Importing ${plan.companies.length} companies...`);
  await importCompanies(plan.companies);

  // Phase 2: Contacts
  console.log(`\n[Phase 2] Importing ${plan.contacts.length} contacts...`);
  const contactStats = await importContacts(plan.contacts);
  console.log(`  Created: ${contactStats.created}, Updated: ${contactStats.updated}, Failed: ${contactStats.failed}`);

  // Phase 3: Tags
  console.log(`\n[Phase 3] Migrating ${plan.tags.length} tags...`);
  await migrateTags(plan.tags);

  // Phase 4: Articles
  const adminList = await client.admins.list();
  const authorId = adminList.admins[0].id;
  console.log(`\n[Phase 4] Migrating ${plan.articles.length} articles...`);
  await migrateArticles(plan.articles, authorId);

  const duration = ((Date.now() - startTime) / 1000 / 60).toFixed(1);
  console.log(`\n=== Migration complete in ${duration} minutes ===`);

  if (contactStats.errors.length > 0) {
    console.log(`\nFailed contacts: ${contactStats.errors.length}`);
    for (const err of contactStats.errors.slice(0, 10)) {
      console.log(`  ${err.email}: ${err.error}`);
    }
  }
}

Step 6: Post-Migration Validation

async function validateMigration(
  expectedCounts: { contacts: number; companies: number; tags: number; articles: number }
): Promise<{ passed: boolean; checks: any[] }> {
  const checks = [];

  // Check contact count
  const contacts = await client.contacts.list({ perPage: 1 });
  checks.push({
    name: "Contact count",
    expected: expectedCounts.contacts,
    actual: contacts.totalCount,
    passed: contacts.totalCount >= expectedCounts.contacts * 0.95, // 95% threshold
  });

  // Check tags exist
  const tags = await client.tags.list();
  checks.push({
    name: "Tag count",
    expected: expectedCounts.tags,
    actual: tags.data.length,
    passed: tags.data.length >= expectedCounts.tags,
  });

  // Check articles
  let articleCount = 0;
  const articles = await client.articles.list();
  for await (const _ of articles) articleCount++;
  checks.push({
    name: "Article count",
    expected: expectedCounts.articles,
    actual: articleCount,
    passed: articleCount >= expectedCounts.articles * 0.95,
  });

  const passed = checks.every(c => c.passed);
  console.log(`\nValidation: ${passed ? "PASSED" : "FAILED"}`);
  for (const check of checks) {
    console.log(`  ${check.passed ? "OK" : "FAIL"} ${check.name}: ${check.actual}/${check.expected}`);
  }

  return { passed, checks };
}

Rollback Procedure

# If migration goes wrong:
# 1. Stop the migration script
# 2. Tag all migrated contacts for identification
# 3. Delete migrated contacts if needed:
#    Search by custom_attributes.migration_date = "today's date"
#    Delete in batches

# Keep source system active during migration
# Only decommission after validation + 2 weeks of parallel run

Error Handling

IssueCauseSolution
409 ConflictDuplicate external_id/emailSearch before create
429 Rate LimitedToo fastAdd delays between batches
422 ValidationBad email/data formatValidate data before import
Partial migrationScript crashedUse idempotent operations, re-run
Missing conversationsAPI doesn't support bulk importContact Intercom support for import

Resources

Info
Category Development
Name intercom-migration-deep-dive
Version v20260423
Size 10.73KB
Updated At 2026-04-26
Language