Duplicate Shopify customer records are the quiet reason your lifecycle reports never quite reconcile. When one shopper exists as two or three customer objects, their orders split across those objects, so a repeat buyer reads as several one-time buyers and every downstream count in Klaviyo or HubSpot inherits the error. The short answer: normalize identifiers, cluster the collisions, preview each pair before you touch it, then merge in Shopify first and reconcile the destination platform second, because the two systems do not dedupe on the same key.
Key takeaways
- The fix in one line: group customers by lowercased email and E.164 phone, confirm each pair with
customerMergePreview, merge with the native tool or thecustomerMergemutation, then reconcile Klaviyo or HubSpot. - Duplicates come from guest checkout followed by account creation, mixed-case or aliased emails, and phone-only orders that never carry an email.
- Shopify merges are permanent and cannot be reversed, and several conditions (subscriptions, store credit, B2B links, vaulted cards, multipass, redaction) block a merge outright.
- Klaviyo does not merge on
external_id, so a clean merge in Shopify does not propagate on its own; you reconcile the profiles by email. - You have finished only when the merged customer's order count equals the sum of the originals and your attributed-revenue totals stop disagreeing between systems.
What you need
- Shopify admin access with the Customers permission, plus the
read_customers,read_customer_mergeandwrite_customer_mergeAPI scopes if you are working through the Admin GraphQL API at scale. - A private API key for your ESP or CRM: a Klaviyo private key with profile read and write, or a HubSpot private app with CRM contact scopes.
- A spreadsheet or a small script (Python, a notebook, or Google Sheets) to group and diff the exported records.
- A full export of your Shopify customers taken immediately before you start, as your only rollback path.
Steps: find the duplicates and merge them without losing history
- Export every customer with its identifiers and order count. From the Shopify admin, open Customers and use Export, or pull them through the Admin GraphQL API so you also get the fields the CSV omits. Request
email,phone,numberOfOrdersandamountSpentfor each record.query { customers(first: 250, query: "") { edges { node { id email phone numberOfOrders amountSpent { amount currencyCode } } } } } - Normalize the identifiers before you compare them. Lowercase and trim every email, and convert every phone number to E.164 (for example
+15005550006), the same format the Klaviyo Profiles API stores in itsphone_numberfield. Two records that look different in the raw export often collapse to one identity after this step. - Cluster the collisions. Group the normalized rows by email, then by phone. Any group holding more than one distinct customer
idis a duplicate cluster. Sort clusters by combinednumberOfOrdersso the records doing the most damage to your reporting surface first. - Preview each pair before you merge it. Run the
customerMergePreviewquery (it needs theread_customer_mergescope) to see which record Shopify will keep and whether anything blocks the merge. ReadresultingCustomerId,blockingFieldsandcustomerMergeErrors; a non-emptyblockingFieldsmeans you cannot merge this pair yet.query { customerMergePreview( customerOneId: "gid://shopify/Customer/111" customerTwoId: "gid://shopify/Customer/222" ) { resultingCustomerId defaultFields { ... } alternateFields { ... } blockingFields customerMergeErrors { errorFields message } } } - Merge in Shopify. For a handful of records, use the admin merge screen and search for the partner profile by first name, last name, email, phone or credit card number. For volume, call the
customerMergemutation and setoverrideFieldsto control which record's attributes win. The mutation returns aresultingCustomerIdthat the documentation says to treat as authoritative, plus ajobyou poll because the merge runs asynchronously.mutation { customerMerge( customerOneId: "gid://shopify/Customer/111" customerTwoId: "gid://shopify/Customer/222" overrideFields: { customerIdOfFirstNameToKeep: "gid://shopify/Customer/111" customerIdOfLastNameToKeep: "gid://shopify/Customer/111" } ) { resultingCustomerId job { id done } userErrors { field message } } } - Reconcile the ESP or CRM, because the merge does not travel with the customer. Klaviyo keys identity on email and phone, not on the Shopify customer id, so merging in Shopify does not merge the matching Klaviyo profiles. Where duplicates already synced, call the Klaviyo merge endpoint to fold the source profile into the destination.
curl -X POST https://a.klaviyo.com/api/profile-merge \ -H "Authorization: Klaviyo-API-Key YOUR_PRIVATE_KEY" \ -H "revision: 2024-10-15" \ -H "content-type: application/json" \ -d '{ "data": { "type": "profile-merge", "id": "DESTINATION_PROFILE_ID", "relationships": { "profiles": { "data": [{ "type": "profile", "id": "SOURCE_PROFILE_ID" }] } } } }'
Why the two systems disagree about who a customer is
Shopify and your ESP resolve identity on different keys, and that gap is where duplicates hide. Shopify's merge tooling matches on name, email, phone and card, and treats a customer object as the unit. Klaviyo ranks identifiers as profile id, then external id, then email, then phone number, and its background identity resolution consolidates profiles when it sees an email address, phone number or device id recognized together, per Klaviyo's identity resolution documentation. The trap sits one level down: the Klaviyo Profiles API overview states plainly that external_id is not involved in profile merging, so its use can lead to duplicate profiles. If your integration writes the Shopify customer id as the external id and expects that to dedupe, it will not. Reconcile on email instead.
Klaviyo will hold up to five email addresses on a single profile through its multi-email feature, with one designated as the primary, as described in Klaviyo's multi-email profiles article. That helps once profiles are merged, but it does not find the duplicates for you.
| Merge surface | Bulk? | Reversible? | Keys on | Blocks on |
|---|---|---|---|---|
| Shopify admin merge screen | No, one pair at a time | No | Name, email, phone, card | Subscriptions, store credit, B2B, vaulted card, multipass, redaction |
Shopify customerMerge mutation | Yes, scriptable per pair | No | Customer ids you pass | Same conditions, surfaced in blockingFields |
Klaviyo POST /api/profile-merge | Yes, one source per call | No | Email and phone | Source profile is deleted after merge |
Troubleshooting
- The preview returns blocking fields. A Shopify merge is refused when either record has, or has ever had, a subscription contract, is a B2B customer linked to a company or past B2B orders, holds a vaulted credit card or a store credit account, uses multipass login, or is deleted or under a data-redaction request. Resolve or migrate that specific condition before retrying; the customerMergePreview reference returns the exact blocking fields per pair.
- The wrong record was kept. When neither customer has an email,
customerTwoIdis the one kept. Otherwise let the preview tell you and useoverrideFieldsto pin the first name, last name and other attributes you want to survive; the customerMerge mutation reference documents the override input. - Duplicates reappear after you merge. If new duplicates keep spawning, the source is upstream: guest checkout with a different email case, a POS lane that captures phone only, or an app writing profiles keyed on
external_id. Fix the capture path, not just the records. - Klaviyo still shows two profiles. The Shopify merge does not cascade. Use Klaviyo's profile-merge endpoint, which queues an asynchronous task that folds the source profile into the destination and then deletes the source. It cannot be undone, so confirm the destination id first.
How to verify it worked
Prove the merge with counts, not with a spot check.
- Order history is intact. On the resulting customer,
numberOfOrdersequals the sum of the two originals andamountSpentequals their combined total. If it does not, orders were stranded and you kept the wrong record. - Identity collisions are gone. Re-run the clustering from step 3 against a fresh export. The count of clusters holding more than one customer id should fall to near zero, and stay there on the next export.
- Downstream counts converge. Your Klaviyo profile count drops by roughly the number of merges, and your repeat-purchase rate and predicted lifetime value stop reading customers as first-time buyers. Split order history is a common reason Klaviyo predicted CLV misjudges your store.
- Revenue reconciles. Attributed revenue that was scattered across duplicate profiles now lands on one, which is the first thing to check when you reconcile Klaviyo revenue against Shopify numbers.