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:
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,
};
}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.
