Skip to content
klaviyo 2026-08-07

Klaviyo Profiles & Newsletter: Where the Docs Won't Save You

Sending external_id created duplicate Klaviyo profiles next to the Shopify sync. Unsubscribe needs a Klaviyo flow with webhook, not an API call.


Klaviyo profile merge diagram showing how external_id creates duplicate profiles next to Shopify email-based profiles

For CTOs, tech leads, and senior developers integrating Klaviyo profile identification or newsletter subscription into a Flutter e-commerce app — or debugging why their Klaviyo profiles are duplicating.

Klaviyo × Flutter series (part 4 of 4): planning & scope · the analytics layer · push notification pitfalls · profiles & newsletter.

TL;DR: Two Klaviyo features that look like 30-minute tasks hide data-corruption traps. Sending external_id alongside email suppressed Klaviyo’s auto-merge and quietly duplicated every profile — the fix was one line removed and one test assertion added. Newsletter unsubscribe has no client-side endpoint; the working pattern routes through a Klaviyo flow with a webhook.


Two features that look trivial in the docs

Profile identification and a newsletter toggle. One API endpoint each. Five-minute integration according to the docs. And both silently corrupted production data — no errors, no crashes, just duplicate profiles accumulating for weeks and unsubscribe requests vanishing into the void.

This post covers two API-level traps I hit during a Klaviyo integration into a production Flutter e-commerce app for a Shopify-based DTC jewelry brand. The overview post maps the full integration scope; the push notification post covers the platform-level pitfalls. This one is about the data layer — where the damage is invisible until someone checks.

Trap 1: external_id quietly duplicates your profiles

Klaviyo’s profile identification API accepts multiple identifiers: email, phone_number, and external_id. The documentation describes external_id as a way to link a Klaviyo profile to an ID from your own system. If your app authenticates via Shopify and you have a Shopify customer ID, passing it as external_id seems like the obvious choice. Stronger identity. More reliable linking.

It is the opposite.

How Klaviyo’s merge logic works

Klaviyo auto-merges profiles when they share an email or phone_number. If the Shopify backend integration creates a profile with email: user@shop.example.com, and your app later identifies a profile with the same email, Klaviyo merges them into one record. Events, properties, list memberships — all unified.

But external_id does not participate in this merge logic. It creates what Klaviyo calls a separate “identifier group.” When your app sends both email and external_id together, Klaviyo treats the external_id as a strong identity anchor. Instead of merging with the existing email-only profile from the Shopify sync, it creates a second profile — same email, different identity silo.

The result: every user who logs into the app gets a duplicate Klaviyo profile. One from the Shopify backend integration (identified by email), one from the app (identified by external_id + email). Same person, two profiles, split event history.

The before and after

The initial implementation passed the Shopify customer ID as external_id:

// BEFORE: external_id suppresses Klaviyo's email-based auto-merge
Future<void> _syncProfile(Customer customer) async {
  await _analyticsDispatcher.setProfile(
    externalId: customer.id,  // Shopify customer ID
    email: customer.email,
    firstName: customer.firstName,
    lastName: customer.lastName,
    properties: {
      if (customer.countryCode != null) 'app.country': customer.countryCode,
    },
  );
}

The fix was removing external_id entirely and identifying by email only:

// AFTER: email-only identification — Klaviyo auto-merges with
// profiles from other sources (Shopify, backend) that share the email.
// Passing external_id alongside email prevents this merge.
Future<void> _syncProfile(Customer customer) async {
  await _analyticsDispatcher.setProfile(
    email: customer.email,
    firstName: customer.firstName,
    lastName: customer.lastName,
    properties: {
      if (customer.countryCode != null) 'app.country': customer.countryCode,
    },
  );
}

One line removed. The tests pin it:

verify(() => analyticsDispatcher.setProfile(
  externalId: null,  // pinned — external_id must not be sent
  email: 'user@shop.example.com',
  firstName: 'Jane',
  lastName: 'Doe',
  properties: {'app.country': 'DE'},
)).called(1);

The externalId: null assertion is deliberate. Without it, someone adding the customer ID back “for better tracking” would pass the test suite and silently reintroduce the duplication.

Why this is hard to catch

The duplication produces no error. The API returns 200 OK. The app works. Events are tracked. The problem only surfaces when a marketer looks at the profile list and sees two entries for the same customer — or when flow emails fire twice, or when segmentation counts are inflated. By then, hundreds or thousands of profiles may be duplicated. Since Klaviyo bills per active profile, every duplicate quietly inflates your marketing costs — a data quality bug that becomes a billing problem.

Retroactively, you can merge profiles manually in the Klaviyo UI or via the profile merge API. But the sustainable fix is upstream: stop creating the duplicates.

The guest-mode corollary

A related decision: what happens when a logged-in user logs out and browses as a guest?

The instinct is to reset Klaviyo’s identity — call something like Klaviyo.resetProfile() to clear the association. The problem: this creates a new anonymous profile in Klaviyo. Every screen view, every product tap from the guest session spawns events attached to a profileless identity. When the user logs back in, those anonymous events do not retroactively merge.

The deliberate choice was to not reset identity on logout. The guest user’s browsing events continue to attach to the last identified profile. This is a trade-off: the data is slightly imprecise (a guest session’s views are attributed to the last logged-in user), but it avoids spawning fresh anonymous profiles that fragment the event history further.

If your app supports multiple users on the same device, this trade-off flips — you would need to reset identity to avoid cross-contamination. For a typical single-user e-commerce app, keeping the identity warm is the better default. Note that attributing unauthenticated browsing to an identified user has privacy implications under GDPR — review this trade-off with your data protection officer before shipping.

Trap 2: subscribe and unsubscribe are asymmetric

Newsletter subscription via Klaviyo’s client API is straightforward. The app sends a POST /client/subscriptions request with the user’s email, the consent state, and the target list ID. No private key needed — the request is scoped by the public API key (company ID).

The subscribe call

Future<void> subscribeToNewsletter({required String email}) async {
  final listId = _newsletterListId;
  if (listId == null || listId.isEmpty) {
    developer.log(
      'KLAVIYO_NEWSLETTER_LIST_ID not configured, skipping subscribe',
      name: 'KlaviyoSubscriptionService',
    );
    return;
  }
  await _dio.post<dynamic>(
    '/client/subscriptions',
    queryParameters: {'company_id': _companyId},
    data: _buildBody(email: email, listId: listId),
  );
}

The JSON:API body follows Klaviyo’s subscription format:

Map<String, dynamic> _buildBody({
  required String email,
  required String listId,
}) {
  return {
    'data': {
      'type': 'subscription',
      'attributes': {
        'custom_source': 'Mobile App',
        'profile': {
          'data': {
            'type': 'profile',
            'attributes': {
              'email': email,
              'subscriptions': {
                'email': {
                  'marketing': {'consent': 'SUBSCRIBED'},
                },
              },
            },
          },
        },
      },
      'relationships': {
        'list': {
          'data': {'type': 'list', 'id': listId},
        },
      },
    },
  };
}

The custom_source field tags the subscription with its origin — useful in Klaviyo’s UI for filtering where subscribers came from. The list ID comes from environment configuration (one list per environment: staging, production). The consent value SUBSCRIBED is Klaviyo’s explicit opt-in marker.

This endpoint works. The profile gets added to the list. The subscribe half is done.

There is no client-side unsubscribe

Here is where the symmetry breaks. Klaviyo’s client API can subscribe a profile to a list. It cannot unsubscribe one. There is no DELETE /client/subscriptions, no consent: 'UNSUBSCRIBED' that you can send from the app. The server-side API (profile-subscription-bulk-delete-jobs) requires a private API key — which must never ship inside a mobile app.

The working pattern uses Klaviyo’s own flow engine as an intermediary:

  1. The app emits a custom event. When the user toggles the newsletter off, the app tracks an app_newsletter_unsubscribe event through the analytics dispatcher. No unsubscribe API call — just an event.

  2. A Klaviyo flow triggers on that metric. In the Klaviyo dashboard, a flow is configured with the trigger “Person does app_newsletter_unsubscribe.” This runs inside Klaviyo’s infrastructure.

  3. The flow calls a webhook. The flow’s action is a webhook that calls Klaviyo’s own profile-subscription-bulk-delete-jobs endpoint, authenticated with the private API key that is stored inside Klaviyo. The private key never leaves Klaviyo’s infrastructure — it is configured in the webhook action, not in the app.

The call site in the app is clean:

if (hasAcceptedNewsletter) {
  await _klaviyoSubscriptionService.subscribeToNewsletter(
    email: customer.email,
  );
} else {
  await _analyticsDispatcher.trackEvent(
    eventName: 'app_newsletter_unsubscribe',
  );
}

Subscribe is a direct API call. Unsubscribe is an event that triggers a flow. Asymmetric by design, because Klaviyo does not expose the unsubscribe capability at the client API security level.

The debugging implication

This asymmetry has a direct consequence for debugging: when a user says “I toggled the newsletter off but I’m still getting emails,” the app is almost certainly not the problem.

The app’s responsibility ends at emitting the app_newsletter_unsubscribe event. You can verify that in the Klaviyo profile’s activity feed — if the event shows up, the app did its job. Everything after that is a Klaviyo-flow problem:

  • Is the flow active?
  • Is the trigger metric spelled correctly?
  • Did the webhook fire?
  • Did the webhook return a success response?

None of those questions are answerable from the app’s logs. They live in Klaviyo’s flow analytics and webhook logs. This means the ops handover documentation for the integration must explicitly state: the app emits the event; the flow handles the unsubscribe; debugging unsubscribe failures requires Klaviyo dashboard access, not app debugging.

In practice, the most common failure mode is the flow being paused or never activated after the initial setup. The app works perfectly. The flow does not exist yet.

Review checklist before launch

Before shipping a Klaviyo integration that touches profiles and newsletter, verify these:

  1. Identity key audit. The app identifies profiles by email (or phone) only. No external_id is sent. Tests pin externalId: null explicitly.

  2. Duplicate check against backend sync. Create a test user via Shopify. Log into the app with the same email. Check Klaviyo’s profile list — there should be exactly one profile, not two. Do this in staging and production.

  3. Unsubscribe flow live and tested. The Klaviyo flow that triggers on app_newsletter_unsubscribe exists, is active, and the webhook is configured with the correct private key and list ID. Toggle the newsletter off in the app and verify the profile is removed from the list within Klaviyo’s flow execution window (typically under a minute).

  4. List ID configured per environment. Staging and production use different Klaviyo newsletter list IDs. Verify both are set in the environment configuration. A missing list ID should skip the subscribe call gracefully (the guard clause in the code above handles this), not crash.

  5. Guest-mode identity behavior documented. Whether identity resets on logout or stays warm is a product decision with data implications. Document it in the handover so the marketing team knows what to expect from guest-session data.

Takeaways for your Klaviyo identity setup

  • external_id is not a stronger identity — it is a separate one. The name suggests it supplements email. In practice, it creates a parallel identity silo that suppresses Klaviyo’s email-based merge. Unless you are the only system writing to Klaviyo, email-only identification is the safe default.
  • Always pin identity behavior in tests. The externalId: null assertion is not a test for the current behavior — it is a guard against a future developer reintroducing the bug. Identity is too subtle to leave unprotected.
  • API asymmetry is a design choice, not a bug. Klaviyo’s client API is scoped to what a mobile app should be able to do with a public key. Unsubscribing someone from a list is a destructive action that requires server-side authentication. The flow-based workaround is verbose, but it respects the security boundary.
  • Ops handover is part of the integration. If the unsubscribe depends on a Klaviyo flow, then the flow is infrastructure. It needs to be documented, monitored, and included in the handover — not set up once and forgotten.

Debugging duplicate Klaviyo profiles or wiring up newsletter flows in your Flutter app? I’ve shipped both fixes in production — book a coffee chat and I’ll walk you through the identity and subscription patterns that actually work.

See how I approach Flutter app development projects end-to-end.

Related reading

Frequently Asked Questions

Why is Klaviyo creating duplicate profiles for my app users?

Most likely your app identifies profiles with an external_id. Klaviyo only auto-merges profiles on email or phone number — an external_id creates a separate identity silo next to the profile your Shopify or backend integration creates from the same email. Identify by email only and the profiles merge. See Trap 1 above for the code change and the test assertion that prevents regression.

Does the external_id duplication problem apply if I am not using Shopify?

Yes. The issue is not Shopify-specific — it applies whenever any other system (backend, CRM, import) creates Klaviyo profiles by email and your app identifies profiles with an external_id. The merge suppression happens at Klaviyo’s identity layer regardless of which integration created the first profile. The fix — removing external_id and pinning externalId: null in tests — is the same regardless of your backend integration.

How can a mobile app subscribe a user to a Klaviyo list?

Via the client API: POST /client/subscriptions scoped by your public API key, with a JSON:API body carrying the email, marketing consent SUBSCRIBED, and a relationship to the target list ID. No private key needed in the app. The full request body and endpoint are documented in the subscribe section above.

Is there a client-side unsubscribe endpoint in Klaviyo?

No. The client API can subscribe but not unsubscribe. A working pattern: the app emits a custom event (e.g. app_newsletter_unsubscribe), and a Klaviyo flow triggers on that metric and calls Klaviyo’s own profile-subscription-bulk-delete-jobs endpoint via webhook with a private key that never leaves Klaviyo. See Trap 2 for the complete flow architecture and debugging guide.

KH
Khalit Hartmann Freelance Mobile & Full-Stack Developer