Nothing syncs between two Shopify stores
This surprises people who assume that two stores under one Plus organisation share something. They share user management and billing. They do not share catalogue. Shopify's own documentation is unusually blunt about it: store settings, products, collections and inventory are not synced between stores, and they do not share data by default. The recommendation that follows is to use third-party apps or an ERP.
So the sync is yours to specify, and two features that sound like they solve it do not. Combined Listings groups variations into one listing within a single store. Shopify Collective genuinely does move product data between two Shopify stores, but it models a supplier selling to a retailer — two businesses, a margin, no inventory cost. If that is actually your relationship, use it. It is not the shape of a wholesale business running its own consumer storefront.
A mirror is the wrong shape
The first instinct is to copy everything and let the two stores match. It fails within a fortnight, because the two stores disagree on purpose. The B2B store carries wholesale pricing and a technical description written for a buyer who knows what a load rating is. The B2C store carries retail pricing, campaign copy, a curated image order and a product status that the retail team controls. Those are not inconsistencies to be reconciled. They are the reason there are two stores.
What travels is technical truth: the specification, the dimensions, the materials, the identifiers. What stays put is everything the retail side has an opinion about. Once it is put that way, one-way synchronisation stops looking like a limitation and starts looking like the design — the B2B store is upstream because that is where products are created, and nothing flows back.
The field map is the architecture
Every field needs exactly one owner, written down. The useful discipline is to make create and update different maps, because they carry different risk. A create is nearly harmless: the product does not exist yet, so anything you set is the only version there has ever been. An update is where the damage happens, and it is silent.
Product status is the clearest case. On create, a new product should arrive as a draft so somebody merchandises it before it is live. On update, status must not travel at all — otherwise every sync run resurrects products the retail team has deliberately archived, and nobody connects the reappearance to a sync that ran at three in the morning. Pricing splits the same way: a recommended retail price can feed the B2C selling price, while wholesale pricing never leaves the B2B store.
This is also where you decide what a sync is allowed to delete. Shopify's write mutations are mostly declarative — what you send is what the product becomes — so an omitted field is a cleared field, not an untouched one. A field map that only lists what to copy is half a specification. It has to say what is protected too.
The key that moves
Then the harder half. To update a product on the other store you have to find it, and the obvious keys are all mutable. SKUs get revised. Handles are derived from titles and change when someone improves a title. Matching on either works until the day it doesn't, and the failure is not an error — it is a duplicate, created quietly, because no match was found.
Shopify's answer is custom IDs, and it is a good one. A metafield of type id is automatically configured to hold unique values, and the documentation says what it is for in almost exactly the terms of this problem: a reliable unique identifier to match resources across multiple shops and systems, naming ERPs, CRMs and PIMs. Since the 2025-04 API version, productSet and productUpdate accept a customId as their identifier and will create the product if no match exists. The productByIdentifier query reads by the same key.
Use it. The precondition is the part to plan around: the identifier has to be on both sides before the first sync, and when you are connecting two catalogues that already exist, it isn't. That makes the first run a different job from every run after it — reconcile once against whatever keys you have, stamp the identifier on both stores, and never match on anything else again. Treat the value as immutable once written. Shopify's documentation does not say what happens if you try to change one, which is reason enough not to.
The one thing that would change this recommendation is scope. Create-or-update by custom ID is documented for products and customers only. Variants, collections, orders and locations can be looked up by custom ID but not upserted by it, so a sync that has to write variants still needs its own resolution step.
The upsert that deletes its own key
There is a sharp edge here, and it is documented behaviour rather than a bug — which makes it easier to hit, because nothing is going to error.
The productSet mutation treats the metafields array as the complete set for that product. When you upsert by customId and send metafields, you must include the identity metafield among them. Leave it out and the mutation deletes the key it just matched on. Shopify confirmed this in January 2026 as intentional, to prevent accidental deletion of the identifying metafield, and noted that handle-based upserts behave differently only because a handle is a native field rather than a metafield.
The shape of the failure is what makes it dangerous. The call succeeds. The product is updated correctly. The identifier is gone, so the next run finds nothing, creates a second product, and the duplicate inherits the identifier. You now have two records and the wrong one is authoritative. The fix is one line, and it belongs in the function that builds the payload rather than in the memory of whoever writes the next integration.
const IDENTITY = { namespace: "weldaad", key: "source_product_id" };
/** Fields the B2B store owns. Anything not listed is the B2C store's to control. */
const OWNED_ON_CREATE = ["title", "descriptionHtml", "productType", "vendor", "status"];
const OWNED_ON_UPDATE = ["title", "descriptionHtml", "productType", "vendor"];
export function buildProductSetVariables({ sourceId, mode, source, metafields = [] }) {
if (!sourceId) throw new Error("sourceId is required — it is the match key");
if (mode !== "create" && mode !== "update") throw new Error(`unknown mode: ${mode}`);
const owned = mode === "create" ? OWNED_ON_CREATE : OWNED_ON_UPDATE;
const input = {};
for (const field of owned) {
if (source[field] !== undefined) input[field] = source[field];
}
// The identity metafield has to be re-sent with every write that carries metafields.
// productSet treats `metafields` as the complete set, so omitting it deletes the key
// this mutation just matched on — and the next run creates a duplicate instead.
const collision = metafields.find(
(m) => m.namespace === IDENTITY.namespace && m.key === IDENTITY.key && m.value !== sourceId,
);
if (collision) {
throw new Error(
`refusing to rewrite the identity metafield: ${IDENTITY.namespace}.${IDENTITY.key} ` +
`would change from ${sourceId} to ${collision.value}`,
);
}
input.metafields = [
{ ...IDENTITY, type: "id", value: sourceId },
...metafields.filter((m) => !(m.namespace === IDENTITY.namespace && m.key === IDENTITY.key)),
];
return {
identifier: { customId: { ...IDENTITY, value: sourceId } },
input,
};
}What this does not solve
Inventory is a different problem with a different cadence, and it does not belong in a product sync — it changes by the minute, it is usually owned by an ERP rather than by either store, and bolting it onto a nightly catalogue job is how stores end up overselling. Nothing here makes two stores into one: orders, customers, discounts and analytics stay separate, and reporting across both is its own piece of work.
Bidirectional sync is a harder problem than it looks and is usually the wrong answer. As soon as both stores can write, you need conflict resolution, and the honest version of conflict resolution is a rule about which store wins per field — which is the field map again, with twice the surface and a race condition. If the retail team genuinely needs to originate products, it is worth asking whether those products should exist in the B2B store at all.
And there is no native option to fall back to. Shopify does not sync catalogue between stores, so whatever you build is the only thing standing between the two, including its failure modes.
When this needs an engineer
Not always. A single store with Markets covers currency, per-market pricing, domains and translation, which is most of what people open a second store for, and if you have not already got two stores for reasons that survive scrutiny that is the cheaper answer. A catalogue of thirty products that changes twice a year does not need an integration; duplicating by hand is honest and the failure mode is visible. If the relationship really is supplier to retailer between two businesses, Collective exists and is supported.
It needs an engineer when two stores have to diverge deliberately and stay technically consistent — a catalogue that changes weekly, products carrying structured metafields that the retail storefront depends on, and a retail team whose merchandising must survive the next sync run. That is a data contract between two systems that happen to run the same platform, and it is the same class of problem as the portal that feeds products into the B2B store in the first place. We built both for the same wholesale business — the product synchronisation between its B2B and B2C operations, and the supplier portal upstream of it — as custom Shopify app development rather than configuration, because a field map is a decision about the business, not a setting.
Send us the store and the symptom.
