Claude-code-plugins-plus klaviyo-migration-deep-dive
install
source · Clone the upstream repo
git clone https://github.com/jeremylongshore/claude-code-plugins-plus-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/jeremylongshore/claude-code-plugins-plus-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/saas-packs/klaviyo-pack/skills/klaviyo-migration-deep-dive" ~/.claude/skills/jeremylongshore-claude-code-plugins-plus-klaviyo-migration-deep-dive && rm -rf "$T"
manifest:
plugins/saas-packs/klaviyo-pack/skills/klaviyo-migration-deep-dive/SKILL.mdsource content
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
SDK installedklaviyo-api- 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 |
|---|---|---|
| | No prefix |
| | camelCase |
| | camelCase |
| | camelCase, E.164 format |
| | Nested under |
| | Nested under |
| | Nested under |
| | Nested under |
| | camelCase |
| | camelCase |
| Custom props | | 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 (upsert) |
| Phone format errors | Non-E.164 format | Pre-validate and format as |
| Rate limited during import | Too fast | Reduce batch size, add delays |
| Missing consent timestamps | Historical data | Set flag |
| Template rendering errors | Incompatible template syntax | Convert to Klaviyo Django template syntax |