Skip to content
Houtini.
Contact
Case Studies ·28 August 2026

The parts Shopify Plus B2B doesn't do (and how we built them)

Shopify Plus does more B2B than most people think - right up until a customer sits on two catalogues and their negotiated volume pricing disappears. I put an industrial hardware distributor's ERP pricing live on it, with one first-party app and nothing extra to host. Here's what native covers, and what I had to build.

Flat illustration: industrial warehouse shelving with boxes and castors beside a descending stepped price ladder

How does a business end up with seventy price lists? One negotiation at a time. A trade tier when an account opens, a buying-group rate, a special catalogue for the customer whose rep shook hands on better numbers years ago. The client - an industrial hardware distributor consolidating five brand websites into one Shopify Plus store - carried all of that in SAP Business One, because the ERP is where the deals were done. The brief was to move it onto Shopify Plus natively: no stack of third-party B2B pricing apps, no separate pricing engine bolted on the side.

I'd argued for that approach in the very first architecture discussions, and the argument is worth repeating because it shaped everything after it: price is a calculation, not storage. Pre-computing a price matrix for a thousand B2B customers across four thousand products means around four million rows - twelve million with volume tiers - all going stale the moment a rep renegotiates. Shopify's native B2B machinery had to carry the pricing, or the project wasn't worth doing.

So, could it? Mostly, yes. The store is live: 348 companies, 591 trade customers, 91 catalogues, orders flowing daily on card, PayPal and account terms (numbers as of August 2026). What follows is the map I wish I'd had at the start: what native covers, and the places I had to build.

The pricing we had to make work

The price lists look like percentage rules from the outside. A list named "minus 60" sounds like a formula - it isn't. Every price list on this store is configured at 0% adjustment, and every B2B price in it is an explicit, hand-set figure pushed from the ERP, over 1,300 prices per list. The names are labels for humans; the numbers are the contract.

Then the special pricing sits on top. Forty-four customers have negotiated catalogues of their own, some carrying their own volume breaks - and a customer on a special catalogue is still on a standard catalogue too. Hold that thought.

The physical world gets a say as well. Much of the range only sells in whole cartons, and a carton is not a suggestion: gloves come 144 pairs to a box, and however you present that, somebody gets confused (early in the build, a pair of gloves appeared to cost over $300 because the carton price was doing the talking - that one produced a long email thread). Companies on Net 30 terms pay on account, never by card, because receivables run through the ERP. And roughly 1,180 trade-only products show "log in for price" while 80 retail lines sell openly beside them - a deliberate channel decision by the client, not a gap in the data.

Five-step flow diagram showing how a B2B price resolves: guest pricing, company catalogue, merged volume ladder, carton rules, payment terms

Every buyer's price resolves through these five steps. Four of them are native Shopify Plus; the merged volume ladder is the one I had to build.

An ERP handles all of this without blinking; it was built for it. The question was how much of it an ecommerce platform could hold.

Native first: what Shopify Plus covered on its own

My stance from the start: use Shopify's native B2B machinery for everything it can do, and write first-party code only where the platform stops. To be fair, reaching for the app store is the pragmatic call for plenty of stores - but every third-party pricing app is another subscription, another data path, another system that can disagree with the ERP about what a price is. When the volume-pricing question later got properly hard, I evaluated the well-known B2B pricing apps properly and reached the same conclusion each time: native Shopify already priced this store correctly for the overwhelming majority of cases, and the edge cases needed a small, precise piece of engineering rather than a platform-on-a-platform.

Native took more of the load than I expected. Companies and company locations map cleanly onto ERP business partners. Catalogues hold explicit per-variant prices, so the hand-set lists imported as exactly what they are. Quantity rules handle carton minimums and increments. Payment terms exist natively, so a Net 30 company checks out on account with no custom code at all. New Customer Accounts gave me a login surface to extend.

Four-layer architecture diagram: Shopify native B2B primitives, a first-party app with checkout functions and account extensions, one nightly Cloudflare Worker, and the theme display layer

Everything the build added, in one picture. The functions run on Shopify's own infrastructure and the Worker sits on a free tier - there is no server to patch on a Sunday.

What the platform couldn't do, I built as one first-party custom app: five Shopify Functions (checkout-time logic, compiled to WASM, run by Shopify itself) and three customer-account UI extensions, plus one Cloudflare Worker on a nightly schedule. Here's the app in Shopify's dev dashboard:

Shopify dev dashboard Apps list showing the first-party B2B portal app and the quantity price sync app, with client names blurred

Names blurred for client confidentiality; the middle app is an unrelated build of ours that shares the partner account.

The app has no backend server. The scaffold exists; nothing is deployed to it. That's honestly my favourite property of the whole build. As an aside, not every function in the app is switched on, and that's deliberate: one sits dormant as a belt-and-braces backstop for freight (the live segregation of trade and retail shipping runs through the shipping apps, keyed on customer tags), and one - an automatic volume discount - must never be registered at all, because it would hand B2B customers a retail promotion and the client's margin with it. Part of the job is knowing which functions stay off.

There's also exactly one third-party concession in the whole design, for a single payment rule. There's a good reason, and it's below.

The big gap: two catalogues, one corrupted ladder

Three days after the volume-pricing work went live, the client's own testing surfaced a problem I watched with disbelief: a negotiated volume price - $160 a carton at quantity, against a flat $170 - simply never appeared. The account sat on two catalogues at once, its standard trade list and its own special catalogue. That's the normal state on this store; specials layer on top of standard terms.

Here's what the platform does with that, and Shopify's own documentation says the first half plainly: when catalogues assigned to the same company price the same product, the store displays the lowest price - "not including quantity rules or volume pricing breaks". In practice the resolution picks one winning catalogue per product and uses only that catalogue's volume breaks. It does not combine ladders across catalogues. In our case the winning catalogue carried a flat price and no breaks, so the negotiated $160 never got a look in. The client's requirement was completely reasonable: customers have to be able to sit on multiple catalogues and take the cheapest price at every quantity, or pricing maintenance becomes a permanent copying exercise between catalogues.

Side-by-side diagram: native Shopify two-catalogue price resolution producing a mixed ladder, versus a single merged volume ladder used by product page, cart and checkout

Both sides show one customer on the same two catalogues. Native resolution uses one catalogue's breaks only; the merged ladder is computed nightly and every surface reads it.

So, the fix. A nightly job reads every catalogue a company sits on, computes the combined cheapest-at-each-quantity ladder, and writes it to a company metafield. The shape is simple (illustrative values, not a customer's):

{
  "product": "castor-200mm",
  "tiers": [
    { "min_qty": 1,  "price": 24.90 },
    { "min_qty": 10, "price": 22.10 },
    { "min_qty": 50, "price": 19.75 }
  ]
}

A cart-transform function then prices cart lines from that ladder at checkout, and the theme renders the same ladder on the product page. Product page, cart and checkout all read the same numbers - which sounds like a low bar until you've watched them disagree. The function is deliberately conservative: it only ever lowers a line, never raises one, and if it errors, checkout proceeds on native pricing.

It does one more thing native quantity pricing can't: it totals quantity across variants. The client's spec was written around sized PPE, and it's the clearest way to say it - gloves ladder at 10, 20 and 40 cartons, and five cartons of Small plus five of Large must count as ten. Native Shopify counts per variant, so mixed-size orders never reached their break. The function sums cartons across a product's sizes and prices the whole set off the total. The client verified the result themselves on a real account: $134.99 single-carton, $125 at 20-plus, $119 at 40-plus - on the product page, in the cart, mixed sizes included. And because the merge happens in the sync, their pricing team keeps working exactly as before: specials go in the special catalogue, standard breaks stay in the standard one, and nobody copies numbers between catalogues to keep the maths honest.

As an aside, there's a small convention now buried in this pricing data that I've grown fond of: where two catalogues deliberately tie on a price, the one that should win is set one cent lower, because Shopify resolves lowest-first and an exact tie falls to an arbitrary tie-break. The merge honours the penny. If you ever audit this store and find near-duplicate prices a cent apart - that's intentional data, not sloppiness.

The other gaps: cartons, PO Boxes and the card option

Cartons, enforced for everyone

The ERP knows every SKU's pack quantity; Shopify natively enforces quantity rules per price list, which covers logged-in trade. But the same storefront serves retail, and a retail buyer free-typing a quantity can break a pack. So carton discipline runs at three levels: 846 quantity rules across the trade catalogues, generated by script from the ERP's pack-quantity master data (the script is idempotent - I can regenerate the lot without fear); a checkout validation function that blocks any quantity that isn't a whole multiple of the carton, whoever the buyer is; and quantity steppers in the theme that snap to carton steps, so honest buyers never meet the error message at all. When they do, it's a plain one: "This item is sold in cartons of 144. Please order in multiples of 144."

PO Boxes, blocked the same day

One August order made it through checkout - a carton of nitrile gloves, paid by card - with a PO Box as the delivery address. The couriers don't deliver to PO Boxes. When the warehouse rang to ask for a street address, the customer offered a remote cattle station instead, which the couriers were not going to reach either, but the PO Box half of that problem was fixable in software and the ops team asked for exactly that. The block went live the same day: a checkout validation that rejects PO Box, GPO Box, Locked Bag and Private Bag addresses, with the pattern matching built carefully enough that the suburb of Box Hill and every "Post Office Lane" in the country still pass.

The part that cost me real time was reading the address at all. A delivery address is Level 2 protected customer data in Shopify's API, and the platform silently skips any function that asks for protected data its app hasn't been granted - no error, no log, no block. The grant needs the scope in the app config, a data-access declaration, and an app reinstall, in that order. It's written down in our runbook now, because no single page of the platform documentation tells you it's a sequence.

The card option that had to go

Companies on payment terms pay on account - their invoicing runs through the ERP's receivables - so the credit-card option had to disappear for them, and only them. The mechanism is a customer tag derived nightly from live company payment terms, reconciled in both directions: put a company on terms and the card vanishes for its buyers; take the terms away and the card comes back. No manual tag housekeeping.

Hiding the method itself held a trap I'll happily hand you: payment customisations match the payment provider's registered name, not the label buyers see. This store's gateway is NAB Transact, which registers as "Cybersource" and displays as "Credit card" - and matching "Credit card" hides precisely nothing. I verified that one empirically, which is a polite way of saying the first attempt did nothing. This is also where the design's one third-party concession lives: the hide rule runs in Shopify's Checkout Blocks app, deliberately, because it already holds the protected-data access our lean first-party app doesn't - taking that scope ourselves would have widened the app's data footprint for the sake of a single rule.

When a validation does nothing, check the binding

If I keep one lesson from this build, it's a sentence long: on Shopify, deployment isn't activation. A deployed Shopify Function is inert until it's bound to a store object - a checkout rule, a cart transform, a delivery customisation - and each function type binds through a different mechanism, none of which your deploy pipeline touches.

I learned how sharp that edge is on the PO Box work. The function deployed cleanly and blocked nothing for hours: its checkout rule had been created against the carton function instead, so PO Boxes sailed through while the logs dutifully showed carton checks. The diagnosis discipline that came out of it: when a validation does nothing, check the binding before the code. And because a rule that reads as active in the admin can still be pointed at the wrong function, I wrote a liveness checker that verifies every function is not just deployed but bound to the right object. Its output is deliberately boring:

Checkout validations:
  [OK]   PO Box block: enabled, bound to 'PO Box Block'
  [OK]   Carton qty: enabled, bound to 'Carton Qty Validation'
Cart transform:
  [OK]   cross-variant volume: 1 cart transform(s) registered

LIVENESS: all critical bindings live.

That checker now runs in my deploy routine, and it would have caught the mis-binding instantly.

The same instinct sent me back for a full hardening pass on the app once the checkout had seen a busy fortnight of changes: dead extensions deleted (twelve thousand lines gone in one commit), OAuth scopes tightened to exactly what the live functions use, and a 40-test suite pinning every function's input and output contract, runnable with one command. The nightly Worker got the equivalent treatment when the catalogue count outgrew its original trigger design - it now runs on its own cron schedule, does the payments-critical tagging first, and refuses to fail silently: a partial night throws, so it shows up in monitoring rather than in a customer phone call. The redesign's receipt is my favourite kind - a commit subject that reads "scheduled run: outcome ok, 45s, sync complete ok:true".

What it costs to run

The standing infrastructure cost of the entire B2B customisation layer is close to zero. The functions execute on Shopify's infrastructure at checkout. The account extensions run in the customer's browser. The one piece of scheduled compute is a nightly Worker on Cloudflare's free tier - a pattern we've leaned on before (see the jobs feed case study ). There is no server, so there is no patching, no certificate renewal, no 2am page about disk space.

What the build spends instead is discipline, and I'd rather pay there. Every function is fail-open by design: if our code ever errors at a live checkout, the checkout proceeds - never block revenue on your own bug. That's a commercial decision, made explicitly and documented per function, and for the blocking validations it's chosen at the checkout rule itself rather than smuggled into the code. I still go back and forth on whether fail-open is right for the carton rule specifically - it's the one place where failing open briefly recreates the problem the rule exists to prevent. For now, revenue wins.

Beyond that, I keep every customisation on the store in a register with verify steps and a rollback recipe, and I treat doc drift as a defect rather than a chore for later. When something needs unwinding - and on a live trading store, one day something will - the recipe already exists.

The questions to ask before you build

If your pricing book looks anything like this one - hand-set lists, negotiated specials, pack quantities, account terms - Shopify Plus is a genuine candidate, and you should treat anyone who says "it just does B2B" and anyone who says "you'll need five apps" with equal suspicion. What you want is a map, and these are the questions that draw it:

  1. What does native cover, exactly, for our pricing? Companies, catalogues with explicit prices, quantity rules and payment terms go further than most people expect. Get the specific list, against your own pricing data.
  2. What happens when a customer sits on two catalogues? This is the question that finds the volume-ladder problem. If the answer doesn't mention lowest-price resolution and what happens to quantity breaks, keep asking.
  3. How do we know a customisation is live, not just deployed? Bindings, a liveness check, and a register with rollback steps. The operational answer matters more than the build answer, because you'll be living with it long after launch.

The pricing book is years of a sales team's negotiating, and the whole job of a build like this is moving it without bending it. Shopify Plus gets you most of the way there; the three questions above tell you where "most" ends for your pricing, and they're worth asking before anyone writes a line of code. If you're staring at an ERP full of negotiated catalogues wondering whether the move is possible: it is. I'd do it again.

Discuss and expand Ask ChatGPT Email LinkedIn