klaviyo-migration-deep-dive

'Execute major Klaviyo migration strategies: from legacy v1/v2 APIs,

5 Tools
klaviyo-pack Plugin
saas packs Category

Allowed Tools

ReadWriteEditBash(npm:*)Bash(node:*)

Provided by Plugin

klaviyo-pack

Claude Code skill pack for Klaviyo (24 skills)

saas packs v1.0.0
View Plugin

Installation

This skill is included in the klaviyo-pack plugin:

/plugin install klaviyo-pack@claude-code-plugins-plus

Click to copy

Instructions

Klaviyo Migration Deep Dive

Overview

Comprehensive guide for migrating to Klaviyo from legacy APIs (v1/v2), competing ESPs (Mailchimp, SendGrid, etc.), or re-platforming with the strangler fig pattern. Covers data migration, API mapping, and validation.

Prerequisites

  • Target Klaviyo account configured
  • klaviyo-api SDK installed
  • Source system access for data export
  • Feature flag infrastructure (for gradual rollout)

Migration Types

Migration Complexity Duration Risk
Klaviyo v1/v2 to current API Low-Medium 1-2 weeks Low
Mailchimp/SendGrid to Klaviyo Medium 2-4 weeks Medium
Custom ESP to Klaviyo High 4-8 weeks High
Full re-platform High 2-3 months High

Instructions

Step 1: Legacy v1/v2 to Current API

The most common migration. Klaviyo deprecated v1/v2 endpoints in favor of the JSON:API REST API.


// ============================================================
// BEFORE: Legacy v1/v2 endpoints (DEPRECATED, will stop working)
// ============================================================

// v1 Track (event tracking)
// POST https://a.klaviyo.com/api/track
// Body: { token: "PUBLIC_KEY", event: "Placed Order", ... }

// v2 List Subscribe
// POST https://a.klaviyo.com/api/v2/list/LIST_ID/subscribe
// Headers: { api-key: "pk_***" }

// v1 Identify (profile creation)
// POST https://a.klaviyo.com/api/identify
// Body: { token: "PUBLIC_KEY", properties: { $email: "..." } }

// ============================================================
// AFTER: Current REST API (revision 2024-10-15)
// ============================================================

import {
  ApiKeySession,
  ProfilesApi,
  EventsApi,
  ProfileEnum,
  EventEnum,
} from 'klaviyo-api';

const session = new ApiKeySession(process.env.KLAVIYO_PRIVATE_KEY!);
const profilesApi = new ProfilesApi(session);
const eventsApi = new EventsApi(session);

// v1 Identify → createOrUpdateProfile
await profilesApi.createOrUpdateProfile({
  data: {
    type: ProfileEnum.Profile,
    attributes: {
      email: 'user@example.com',     // was $email
      firstName: 'Jane',              // was $first_name
      lastName: 'Doe',                // was $last_name
      phoneNumber: '+15551234567',    // was $phone_number
      properties: {                   // custom properties stay the same
        plan: 'pro',
        signupDate: '2024-01-15',
      },
    },
  },
});

// v1 Track → createEvent
await eventsApi.createEvent({
  data: {
    type: EventEnum.Event,
    attributes: {
      metric: {
        data: { type: 'metric', attributes: { name: 'Placed Order' } },
      },
      profile: {
        data: { type: ProfileEnum.Profile, attributes: { email: 'user@example.com' } },
      },
      properties: {
        orderId: 'ORD-123',
        items: [{ name: 'Widget', price: 29.99 }],
      },
      value: 29.99,
      time: new Date().toISOString(),
      uniqueId: 'ORD-123',
    },
  },
});

// v2 List Subscribe → subscribeProfiles (bulk)
await profilesApi.subscribeProfiles({
  data: {
    type: 'profile-subscription-bulk-create-job',
    attributes: {
      profiles: {
        data: [{
          type: ProfileEnum.Profile,
          attributes: {
            email: 'user@example.com',
            subscriptions: {
              email: { marketing: { consent: 'SUBSCRIBED', consentTimestamp: new Date().toISOString() } },
            },
          },
        }],
      },
    },
    relationships: {
      list: { data: { type: 'list', id: 'LIST_ID' } },
    },
  },
});

Step 2: API Field Mapping (v1/v2 to Current)

v1/v2 Field Current API Field Notes
$email email No $ prefix
$first_name firstName camelCase
$last_name lastName camelCase
$phone_number phoneNumber camelCase, E.164 format
$city location.city Nested under location
$region location.region Nested under location
$country location.country Nested under location
$zip location.zip Nested under location
$title title camelCase
$organization organization camelCase
Custom props properties.yourProp Same structure

Step 3: Competitor Migration (Mailchimp/SendGrid)


// Data migration adapter -- transform competitor data to Klaviyo format

interface CompetitorContact {
  email_address: string;
  first_name: string;
  last_name: string;
  phone: string;
  tags: string[];
  status: 'subscribed' | 'unsubscribed' | 'cleaned';
  stats: { avg_open_rate: number; avg_click_rate: number };
}

function transformToKlaviyo(contact: CompetitorContact) {
  return {
    data: {
      type: 'profile' as const,
      attributes: {
        email: contact.email_address,
        firstName: contact.first_name,
        lastName: contact.last_name,
        phoneNumber: contact.phone ? formatE164(contact.phone) : undefined,
        properties: {
          migrationSource: 'mailchimp',
          migratedAt: new Date().toISOString(),
          previousTags: contact.tags,
          historicalOpenRate: contact.stats.avg_open_rate,
          historicalClickRate: contact.stats.avg_click_rate,
        },
      },
    },
  };
}

// Batch import with progress tracking
async function migrateContacts(contacts: CompetitorContact[]): Promise<{
  imported: number;
  skipped: number;
  failed: string[];
}> {
  let imported = 0;
  let skipped = 0;
  const failed: string[] = [];

  for (let i = 0; i < contacts.length; i += 50) {
    const batch = contacts.slice(i, i + 50);

    const results = await Promise.allSettled(
      batch.map(async contact => {
        // Skip unsubscribed/cleaned -- don't import suppressed contacts
        if (contact.status !== 'subscribed') {
          skipped++;
          return;
        }

        const payload = transformToKlaviyo(contact);
        await profilesApi.createOrUpdateProfile(payload);
        imported++;
      })
    );

    results.forEach((r, idx) => {
      if (r.status === 'rejected') {
        failed.push(batch[idx].email_address);
      }
    });

    console.log(`Progress: ${Math.min(i + 50, contacts.length)}/${contacts.length} (${imported} imported, ${skipped} skipped)`);

    // Respect rate limits
    await new Promise(r => setTimeout(r, 1000));
  }

  return { imported, skipped, failed };
}

Step 4: Strangler Fig Pattern (Gradual Migration)


// src/email/service-router.ts

interface EmailService {
  sendCampaign(campaign: CampaignData): Promise<void>;
  trackEvent(event: EventData): Promise<void>;
  getProfile(email: string): Promise<ProfileData>;
}

class LegacyEmailService implements EmailService { /* ... */ }
class KlaviyoEmailService implements EmailService { /* ... */ }

/**
 * Route requests between legacy and Klaviyo based on feature flag.
 * Gradually increase Klaviyo percentage from 0% to 100%.
 */
class MigrationRouter implements EmailService {
  constructor(
    private legacy: EmailService,
    private klaviyo: EmailService,
    private getKlaviyoPercentage: () => number  // Feature flag
  ) {}

  private useKlaviyo(): boolean {
    return Math.random() * 100 < this.getKlaviyoPercentage();
  }

  async trackEvent(event: EventData): Promise<void> {
    if (this.useKlaviyo()) {
      // Send to Klaviyo
      await this.klaviyo.trackEvent(event);
    } else {
      // Send to legacy
      await this.legacy.trackEvent(event);
    }

    // During migration: dual-write to both for comparison
    // Remove dual-write after validation
  }

  async sendCampaign(campaign: CampaignData): Promise<void> {
    // Campaigns always go through one system at a time
    if (this.getKlaviyoPercentage() >= 100) {
      return this.klaviyo.sendCampaign(campaign);
    }
    return this.legacy.sendCampaign(campaign);
  }
}

Step 5: Post-Migration Validation


async function validateMigration(sampleSize = 100): Promise<{
  passed: boolean;
  checks: Array<{ name: string; passed: boolean; details: string }>;
}> {
  const checks = [];

  // 1. Profile count comparison
  const profiles = await fetchAllPages(cursor => profilesApi.getProfiles({ pageCursor: cursor }));
  checks.push({
    name: 'Profile count',
    passed: profiles.length >= expectedProfileCount * 0.95,
    details: `Found ${profiles.length}, expected ~${expectedProfileCount}`,
  });

  // 2. Sample profile data integrity
  const sample = profiles.slice(0, sampleSize);
  let dataMatchCount = 0;
  for (const profile of sample) {
    const sourceData = await getSourceProfileData(profile.attributes.email);
    if (sourceData && profile.attributes.firstName === sourceData.first_name) {
      dataMatchCount++;
    }
  }
  checks.push({
    name: 'Data integrity',
    passed: dataMatchCount / sampleSize > 0.98,
    details: `${dataMatchCount}/${sampleSize} profiles match source data`,
  });

  // 3. List membership verification
  const lists = await listsApi.getLists();
  checks.push({
    name: 'Lists created',
    passed: lists.body.data.length >= expectedListCount,
    details: `Found ${lists.body.data.length} lists`,
  });

  return {
    passed: checks.every(c => c.passed),
    checks,
  };
}

Migration Checklist

  • [ ] Export all contacts from source system
  • [ ] Map fields to Klaviyo format (camelCase, E.164 phones)
  • [ ] Exclude suppressed/bounced contacts from import
  • [ ] Create lists in Klaviyo before import
  • [ ] Import profiles in batches (50-100 per batch, with delays)
  • [ ] Verify subscription consent timestamps
  • [ ] Recreate segments in Klaviyo
  • [ ] Migrate email templates
  • [ ] Rebuild flows (welcome series, abandoned cart, etc.)
  • [ ] Validate data integrity with sample checks
  • [ ] Switch DNS/tracking domain to Klaviyo
  • [ ] Monitor deliverability for 2 weeks post-migration
  • [ ] Decommission legacy system after 30-day validation

Error Handling

Issue Cause Solution
Duplicate profiles Same email imported twice Use createOrUpdateProfile (upsert)
Phone format errors Non-E.164 format Pre-validate and format as +{country}{number}
Rate limited during import Too fast Reduce batch size, add delays
Missing consent timestamps Historical data Set historicalImport: true flag
Template rendering errors Incompatible template syntax Convert to Klaviyo Django template syntax

Resources

Ready to use klaviyo-pack?