Skip to content
HeapByte — Senior Shopify Engineering Studio

Integration · 16 October 2026

B2B price list synchronization: mapping ERP customer agreements to Shopify Catalogs

A commercial contract supplier manages fifty B2B dealer agreements inside Exact Online, each carrying negotiated wholesale price lists, tiered quantity breaks, and customer-specific discounts across twelve hundred active SKUs. At midnight, their automated connector pushes updated prices into Shopify. By morning, trade account managers are fielding urgent distributor enquiries: a premier distributor's revised contract updated sixty lines, but fifteen promotional items removed from their ERP agreement were never deleted in Shopify, leaving old discounted prices active. Simultaneously, retail shoppers on the consumer storefront are seeing wholesale net rates because the sync script updated base variant prices instead of isolating the B2B catalog context.

8 min read
Written by the HeapByte engineering team

Shopify B2B price list

The failure of updating variant prices directly

When developers connect an ERP to Shopify without using native B2B primitives, they attempt to manage wholesale pricing through customer tags and variant price overwrites.

In legacy setups, an ERP sync script checks customer tags (such as tier_gold) and uses theme JavaScript or third-party apps to apply percentage discounts on the product detail page. Alternatively, integrations create duplicate 'wholesale variants' or entire hidden duplicate products.

As documented in our work on multi-store product synchronisation, maintaining duplicate products creates data sprawl, fragments inventory across multiple handles, and requires custom match hierarchies. Writing wholesale prices directly to ProductVariant.price exposes those prices to every guest visitor browsing the public storefront.

On Shopify Plus, B2B pricing is decoupled from product variants. The base variant retains its retail price. Wholesale pricing is layered over the variant via B2B Catalogs linked to authenticated CompanyLocation entities. Overwriting base variant prices destroys this separation.

The Shopify B2B Catalog and PriceList data model

Modern Shopify B2B structures commercial pricing around three interrelated resources: Company and CompanyLocation (representing the corporate buyer and their discrete ordering branches), Catalog (governing product availability and pricing for one or more locations), and PriceList (defining currency, parent adjustments, and fixed price overrides for specific variants).

When an authenticated B2B buyer logs into the store, Shopify resolves their company location and binds their session to the assigned Catalog. If a variant has a fixed price override in that Catalog's PriceList, the buyer sees that exact contract rate. If no fixed override exists, the price falls back to the Catalog's percentage adjustment, and finally to the base variant price.

This architecture enables ERP integrations to mirror complex debtor agreements cleanly. Instead of altering product records, the ERP integration targets the specific PriceList associated with the customer's account.

The orphaned price trap: why updates require deletions

The most dangerous defect in custom ERP pricing integrations is the orphaned price leak.

Consider an annual contract review. A distributor had negotiated special net pricing on twenty seasonal items. At the end of the promotional period, those twenty items are removed from the customer's price list in Exact Online, returning them to standard wholesale rates.

When the ERP integration runs that night, its export query only returns active contract lines. Naive connectors iterate through the feed and call priceListFixedPricesAdd for each present SKU. Because the dropped twenty items are not in the ERP feed, the script never touches them.

In Shopify's GraphQL API, priceListFixedPricesAdd is additive: it creates or updates price overrides for the supplied variants, but it leaves all existing price overrides intact. The twenty dropped promotional items remain in the Shopify PriceList indefinitely. The customer continues purchasing those products at expired, heavily discounted rates for months without anyone noticing until gross margin audits reveal the loss.

A correct reconciliation engine must calculate set differences: it queries the existing fixed prices in the Shopify PriceList, compares them against the ERP active snapshot, and explicitly calls priceListFixedPricesDelete for any variant that no longer belongs in the agreement.

Navigating the tax exclusivity mismatch between ERP and Shopify

In industrial ERP systems like Exact Online, wholesale prices are stored strictly exclusive of tax/VAT. Wholesale buyers reclaim VAT, and inter-community European trade applies reverse-charge rules.

Conversely, retail Shopify storefronts in the UK, Europe, and Australia are frequently configured with tax-inclusive pricing in Store Settings (where displayed prices include VAT).

When an ERP integration pushes a €100.00 net wholesale price into Shopify, an immediate conflict arises if catalog tax settings are misaligned: if the B2B catalog inherits tax-inclusive settings from the market, Shopify assumes that €100.00 already includes 21% Dutch BTW. At checkout, Shopify deducts the tax for tax-exempt B2B buyers, reducing the item price to €82.64. The merchant loses 21% of their wholesale margin on every order.

ERP connectors must verify that B2B Catalogs enforce tax-exclusive pricing, pass raw net decimals, and leave tax calculation to Shopify's native tax services.

Diff-based reconciliation and 250-item GraphQL batching

Calling GraphQL mutations item-by-item is impractical for enterprise catalogues. A catalogue with 10,000 price records would consume 10,000 API mutations, immediately exhausting Shopify's GraphQL rate limits.

The Shopify GraphQL Admin API priceListFixedPricesAdd mutation accepts up to 250 prices per call. Similarly, priceListFixedPricesDelete accepts up to 250 variant IDs per call.

Our reconciliation engine processes ERP agreements as diff operations, filtering unchanged prices, isolating additions and updates, and chunking both updates and deletions into maximal 250-item batches:

ts
export interface ExistingPriceListEntry {
  variantId: string;
  price: string;
}

export interface ErpContractLine {
  sku: string;
  variantId: string;
  netPrice: number;
}

export interface PriceListPriceInput {
  variantId: string;
  price: { amount: string; currencyCode: string };
}

export const SHOPIFY_PRICE_LIST_BATCH_LIMIT = 250;

export function reconcilePriceListAgreements(params: {
  priceListId: string;
  catalogCurrency: string;
  currentShopifyPrices: Map<string, ExistingPriceListEntry>;
  erpAgreementItems: ErpContractLine[];
}) {
  const { priceListId, catalogCurrency, currentShopifyPrices, erpAgreementItems } = params;
  const warnings: string[] = [];
  const errors: string[] = [];

  const pricesToMutate: PriceListPriceInput[] = [];
  const variantIdsToDelete: string[] = [];
  const erpVariantMap = new Map<string, ErpContractLine>();

  // 1. Deduplicate and validate ERP lines
  for (const erpLine of erpAgreementItems) {
    if (!erpLine.variantId || !erpLine.variantId.startsWith("gid://shopify/ProductVariant/")) {
      errors.push(`Invalid variant GID for SKU "${erpLine.sku}": "${erpLine.variantId}"`);
      continue;
    }
    if (erpVariantMap.has(erpLine.variantId)) {
      warnings.push(`Duplicate ERP line for variant ${erpLine.variantId} (SKU ${erpLine.sku}). Last record wins.`);
    }
    if (erpLine.netPrice <= 0 || !Number.isFinite(erpLine.netPrice)) {
      errors.push(`Non-positive wholesale price (${erpLine.netPrice}) rejected for SKU "${erpLine.sku}".`);
      continue;
    }
    erpVariantMap.set(erpLine.variantId, erpLine);
  }

  // 2. Evaluate Additions and Updates
  let additionsCount = 0;
  let updatesCount = 0;
  let unchangedCount = 0;

  for (const [variantId, erpLine] of erpVariantMap.entries()) {
    const formattedPrice = erpLine.netPrice.toFixed(2);
    const existing = currentShopifyPrices.get(variantId);

    if (!existing) {
      additionsCount++;
      pricesToMutate.push({
        variantId,
        price: { amount: formattedPrice, currencyCode: catalogCurrency.toUpperCase() },
      });
    } else {
      const existingPriceNum = parseFloat(existing.price);
      if (Math.abs(existingPriceNum - erpLine.netPrice) > 0.001) {
        updatesCount++;
        pricesToMutate.push({
          variantId,
          price: { amount: formattedPrice, currencyCode: catalogCurrency.toUpperCase() },
        });
      } else {
        unchangedCount++;
      }
    }
  }

  // 3. Compute Deletions: Variants in Shopify but absent from ERP agreement
  for (const [variantId] of currentShopifyPrices.entries()) {
    if (!erpVariantMap.has(variantId)) {
      variantIdsToDelete.push(variantId);
    }
  }

  // 4. Chunk into 250-item GraphQL batches
  const addUpdateBatches: Array<{ prices: PriceListPriceInput[] }> = [];
  for (let i = 0; i < pricesToMutate.length; i += SHOPIFY_PRICE_LIST_BATCH_LIMIT) {
    addUpdateBatches.push({
      prices: pricesToMutate.slice(i, i + SHOPIFY_PRICE_LIST_BATCH_LIMIT),
    });
  }

  const deleteBatches: Array<{ variantIds: string[] }> = [];
  for (let i = 0; i < variantIdsToDelete.length; i += SHOPIFY_PRICE_LIST_BATCH_LIMIT) {
    deleteBatches.push({
      variantIds: variantIdsToDelete.slice(i, i + SHOPIFY_PRICE_LIST_BATCH_LIMIT),
    });
  }

  return {
    priceListId,
    currencyCode: catalogCurrency.toUpperCase(),
    addUpdateBatches,
    deleteBatches,
    stats: {
      totalErpLines: erpAgreementItems.length,
      currentShopifyEntries: currentShopifyPrices.size,
      additionsCount,
      updatesCount,
      deletionsCount: variantIdsToDelete.length,
      unchangedCount,
    },
    warnings,
    errors,
  };
}
Why the reconciliation engine computes explicit deletions alongside price updates: in an early iteration of our Exact Online wholesale sync, our connector only synchronized items present in the latest ERP price list export. When our client renegotiated a dealer agreement and dropped fifteen promotional SKUs back to standard wholesale, those variants were omitted from the sync file. Because Shopify B2B price lists retain fixed price overrides until explicitly deleted, those fifteen items remained permanently discounted for that distributor. Reconciling ERP snapshots against Shopify price lists and calling priceListFixedPricesDelete is the only way to prevent margin leaks.

What Shopify B2B Price List APIs cannot do

Understanding platform limitations prevents structural mistakes during architecture design:

  • Cannot set prices across multiple currencies in one PriceList: A Shopify PriceList has a single immutable currencyCode. If an ERP customer agreement supports transactions in both EUR and GBP, the merchant must provision two distinct Catalogs and PriceLists and assign them to separate currency-specific company locations.
  • Cannot apply B2B pricing to unauthenticated storefront sessions: Price lists evaluate only after a buyer authenticates via B2B customer accounts. Guest checkouts cannot receive catalog price overrides, as noted in our analysis of [payment customization B2B rules](/insights/shopify-payment-customization-b2b-rules).
  • Cannot exceed 250 items per mutation batch: Sending 251 items to priceListFixedPricesAdd throws a GraphQL user error. Batch slicing must be strictly enforced in middleware.
  • Cannot calculate dynamic shipping rates: Price lists govern merchandise lines only. Shipping rates must be computed separately via Carrier Service or Delivery Customization functions, as outlined in [dimensional shipping rates](/insights/shopify-dimensional-shipping-rates).

When this needs an engineer

You do not need an integration engineer if your B2B wholesale pricing consists of a simple storewide percentage discount (for example, 'all approved wholesale customers get 25% off retail'). Shopify's native B2B admin allows merchants to create percentage-based Catalogs without touching code.

You need an engineer when your business operates on contract agreements: when ERP systems maintain customer-specific net prices, tiered quantity volume breaks, multi-currency price lists, and scheduled contract expirations that must synchronise to Shopify B2B Catalogs without margin leaks or API rate-limit failures.

Explore our ERP and CRM integration case studies to see production implementations connecting Exact Online and enterprise systems, or learn about our custom ERP and CRM integration services. Send us the store and the symptom.

Insights

Apply this to your store.

An audit turns the general principle into a specific list of changes, ordered by what actually pays back.