Skip to content
owerczuk.dev
Medusa.js
B2B
Headless Commerce
Price Lists
Open Source

B2B Commerce with Medusa v2: Company Accounts, Price Lists, Quotes

What Medusa v2 gives you for B2B out of the box, what the official B2B Starter adds and what you build yourself. Plus four pitfalls around pricing and order access that I found and fixed in the code of a Medusa project.

12 min

B2B stores rarely fail on the catalog. They fail on the rules behind it: reseller A gets 18 % off list, a different tier applies from 50 units, orders above €5,000 need sign-off from procurement, and the quote from the sales rep somehow has to end up in checkout. Those rules are exactly why B2B teams look at a framework like Medusa in the first place.

Cards on the table: I don't run a pure B2B store. I build with Medusa and Mercur, currently on a marketplace project running Medusa 2.21.2 and Mercur 2.3.6, and that is where the building blocks of every B2B setup live: price lists, price rules, custom line item prices and access to orders. The four pitfalls further down come from that code, not from the docs. Everything else is checked against official sources as of 19 October 2026.

TL;DR: Customer pricing, tiered prices and separate B2B catalogs are in Medusa's core. Company accounts, spending limits, approvals and quotes come from the official B2B Starter as code you own. Pay-by-invoice, reverse charge VAT and e-invoicing you build yourself or take from your ERP. And before you go live, check two things: who can read which order, and whether discount codes apply on top of prices you already negotiated.

What does Medusa give you for B2B out of the box?

More than most people expect, but not everything the marketing page implies. The line runs between core, starter and project work:

RequirementWhere it comes fromBuilding block
Customer-specific pricesCoreCustomer groups + price lists + price rules
Tiered pricingCoremin_quantity / max_quantity per price
Separate B2B catalogCoreSales channel + publishable API key
Net price displayCoreTax-inclusive or tax-exclusive prices per region/currency
Editing orders after the factCoreOrder edits, draft orders
Company accounts, employees, rolesB2B Startercompany module
Spending limits per employeeB2B StarterSpending limits
Approvals by procurement or by the merchantB2B Starterapproval module
Quotes (RFQ) with negotiationB2B Starterquote module
Pay-by-invoice, payment termsProject workCustom payment provider
VAT ID checks and reverse chargeProject workCustom tax provider or ERP
ERP prices and stockProject workWorkflows + scheduled jobs

Medusa's own B2B recipe describes the core part exactly like this: a dedicated sales channel so that B2B customers only see B2B products, one customer group per company, and price lists with that group as a condition. For companies and employees, the docs explicitly recommend a custom module with models such as Company and Employee, connected through module links. That is not a gap, it is Medusa's design principle: the core models commerce, you model your organisation.

What's inside the official B2B Starter?

The B2B Starter is a complete template, not a plugin: a pnpm monorepo with apps/backend and apps/storefront (Next.js 15), MIT-licensed. The backend contains three custom modules, company, quote and approval, and as of 19 October 2026 it runs on @medusajs/medusa 2.21.2, the current version on npm.

Features per the README: company management with invited employees, spending limits per employee, approval workflows (company admins approve carts, merchants approve orders before fulfillment), quote management with messaging between customer and merchant, order editing, bulk add-to-cart and promotions. Medusa also explains the quote flow in a dedicated guide: the merchant can reject or send a counter-offer, and once the customer accepts, the quote becomes an order.

Old repository in many tutorials

Many guides still link to medusajs/b2b-starter-medusa. That repository is archived, marked as deprecated and was last tested with Medusa 2.4. Start with medusajs/b2b-starter.

Locally, the starter runs in a few minutes:

git clone https://github.com/medusajs/b2b-starter.git
cd b2b-starter && pnpm install
cp apps/backend/.env.template apps/backend/.env   # set DATABASE_URL
cd apps/backend
pnpm medusa db:migrate
pnpm medusa user -e admin@example.com -p <password>
cd ../.. && pnpm dev   # Admin: localhost:9000/app, storefront: localhost:8000

Then copy the key from Settings → Publishable API Keys in the admin into apps/storefront/.env.local. If you have never set up Medusa before, my Medusa tutorial (in German) covers the basics including Postgres and Redis.

The most important property isn't in the README: the starter is a starting point to fork. You take over the code, and from the first commit it's yours. That's good, because no two companies have the same approval chain. It also means updates to the starter don't reach you automatically. Medusa updates come through the packages as usual; improvements to the company, quote and approval modules you port by hand.

How do you model customer prices and tiered prices?

With price rules. Every price in Medusa's Pricing Module can carry conditions, such as the customer group, and a quantity range. The price rules documentation shows both; together they give you customer-specific tiers:

await pricingModuleService.addPrices({
  priceSetId: "pset_bolt_m8",
  prices: [
    // List price for everyone
    { amount: 49, currency_code: "eur" },
    // Reseller price
    { amount: 42, currency_code: "eur", rules: { "customer.groups.id": "cusgrp_resellers" } },
    // Reseller price from 50 units
    {
      amount: 38,
      currency_code: "eur",
      min_quantity: 50,
      rules: { "customer.groups.id": "cusgrp_resellers" },
    },
  ],
})

Day to day, nobody creates these in code. You maintain price lists in the admin or via CSV import, and Medusa knows two types: sale shows the list price struck through next to it, override replaces it silently. For negotiated B2B terms, override is almost always right. A business customer should see their price, not a promotional discount.

Worth knowing for your storefront: net or gross display is a setting per region or currency in Medusa (tax-inclusive pricing), not a template question. A B2B channel with net prices and a B2C channel with gross prices can cleanly share one backend.

Which four pitfalls should you know before going live?

None of them is a bug in the strict sense. They are defaults that make sense for a B2C store and cost money or confidentiality in a B2B context.

Pitfall one: discount codes apply to negotiated prices too. A cart line item in Medusa is discountable by default, even when its price comes from a price list. A 10 % newsletter coupon therefore lands on top of a reseller price that is already 18 % off. In the project we solve this with the MIT-licensed plugin @webbers/webbers-medusa: a toggle per price list, plus a custom add-to-cart endpoint that sets is_discountable: false on those line items. Whether you use a plugin or build it yourself: decide deliberately whether terms and promotions are allowed to stack.

Pitfall two: anyone who knows an order ID can read the order. GET /store/orders/:id is reachable without authentication on purpose. Medusa says so in the source code and explains it in a dedicated guide: guest customers have no session, and the confirmation page still has to load the order. The ID is the password. For a B2C store that's a reasonable trade-off. In B2B, order IDs travel through emails, tickets, ERP exports and forwards to colleagues, and the order contains your negotiated prices. The guide recommends a middleware that checks both authentication and ownership:

// src/api/middlewares.ts
import {
  authenticate,
  defineMiddlewares,
  type AuthenticatedMedusaRequest,
  type MedusaNextFunction,
  type MedusaResponse,
} from "@medusajs/framework/http"
import { ContainerRegistrationKeys, MedusaError } from "@medusajs/framework/utils"

async function ensureOrderOwner(
  req: AuthenticatedMedusaRequest,
  _res: MedusaResponse,
  next: MedusaNextFunction
) {
  const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
  const { data: [order] } = await query.graph({
    entity: "order",
    fields: ["customer_id"],
    filters: { id: req.params.id },
  })
  if (!order || order.customer_id !== req.auth_context.actor_id) {
    return next(new MedusaError(MedusaError.Types.NOT_FOUND, "Order not found"))
  }
  next()
}

export default defineMiddlewares({
  routes: [
    {
      matcher: "/store/orders/:id",
      method: ["GET"],
      middlewares: [authenticate("customer", ["session", "bearer"]), ensureOrderOwner],
    },
  ],
})

Two details from my project that the guide doesn't mention. First: POST /store/orders/:id/transfer/request and .../transfer/cancel also return the full order and don't check the owner; we locked them down with the same rule. Second: if you allow guest orders, you need an exception, or the confirmation page breaks. In a pure B2B store with mandatory login, the question doesn't come up.

Pitfall three: owning a price list doesn't mean you may set every price in it. This affects you as soon as not only your own team maintains prices, but also resellers, sales subsidiaries or marketplace vendors. Check whether your write endpoints only verify that the price list belongs to the sender, or also every single price row including its rules. Price rules are powerful: a price without a restricting rule applies to everyone, and a price with someone else's rule can override someone else's terms. In our project a middleware therefore validates every row before each write, not just the container. If you're planning a B2B marketplace with Mercur, this test belongs in acceptance; more on the marketplace side in my Mercur guide.

Pitfall four: custom line item prices are frozen. The B2B classic is the price from the ERP: on add-to-cart, the storefront looks up the customer price and sets it as a custom line item price; Medusa markets this as custom line item pricing. You should know two properties, both verified in our project on 2.21.2. Such a price is not recalculated when the quantity changes later; if the customer goes from 10 to 60 units, the 10-unit tier price stays until your code updates it. And when the cart changes region, Medusa's updateCartWorkflow removes line items with is_custom_price, while regular line items stay. Both are solvable, with a workflow hook on quantity changes and a clear rule for when regions may change. It just has to be in the project plan.

What's missing for European, and especially German, B2B?

Three topics that no English Medusa tutorial covers and that show up in almost every German B2B project. If you sell into the EU, at least the first two apply to you as well.

Pay-by-invoice. In German B2B, paying by invoice with payment terms is the norm, not credit cards. Providers such as Billie or Mondu take over credit checks and default risk. Medusa connects payment methods through payment provider modules; in my research I found no official, maintained plugin for Billie or Mondu, only integration services offered by agencies. Plan the integration as its own work package. For existing customers, the simplest option is often an "invoice" payment method without an external provider, enabled only for specific customer groups, with the credit check where it already happens today: in the ERP.

VAT IDs and reverse charge. For intra-EU supplies to businesses with a valid VAT ID, no German VAT is charged. Medusa's Tax Module calculates with tax rates per region and can be extended with custom tax providers; validating VAT IDs (for example against VIES) and the reverse charge logic are not part of the core. It's a manageable extension, but one your tax adviser should sign off.

E-invoicing. Since 1 January 2025, businesses in Germany must be able to receive e-invoices. From 2027, businesses with more than €800,000 in prior-year revenue must also issue them for B2B, and from 2028 everyone must. Medusa doesn't generate XRechnung or ZUGFeRD. In most mid-market projects that's not a problem, because the invoice is created in the ERP anyway and the store only hands over the order. But settle that boundary at the start, not right before the deadline.

For the ERP integration itself, Medusa has a dedicated recipe: workflows for order handover and price lookups, scheduled jobs for stock and master data. This is where Medusa plays to its strengths. An order handover as a workflow with compensation steps is more robust than the cron script hanging off a plugin hook in many shop systems.

Medusa, Shopify Plus or Shopware for B2B?

The honest answer depends less on features than on your team. Shopify Plus ships B2B features but is priced through subscription and transaction fees; my Medusa vs. Shopify cost comparison shows where the lines cross. In the German market, Shopware ships B2B components ready-made in its commercial plans and has an unrivalled agency network; details in my Medusa vs. Shopware comparison (in German).

Medusa wins when at least two of these apply:

  1. Your approval chains, spending limits or pricing logic don't fit any off-the-shelf module.
  2. The ERP is the source of truth for prices and stock, and the store should adapt to it, not the other way around.
  3. You serve B2B and B2C from one backend with separate channels.
  4. Your team works in TypeScript, or you have a partner who does.

If none of these apply, buy rather than build. A standard B2B store with customer pricing and a login doesn't need a framework.

How do you start a B2B project with Medusa?

With the B2B Starter on your own catalog, not demo data. Import a hundred real products, set up three real customer groups with their price lists and run through the one approval chain that causes the most trouble in your company. Afterwards you'll know whether the starter covers 70 % of your needs or 30 %. And test the four pitfalls above before a customer does.

That is exactly what the two-week PoC sprint is for: your catalog, your pricing logic, your ERP connection as a prototype, ending with a solid effort estimate instead of a hope. If you'd like to talk first, book a call and bring your most complicated pricing rule. Where the backend will run later is covered in the deployment comparison, all articles in the series are collected in the e-commerce topic, and the bigger picture beyond Medusa is in my open-source e-commerce platforms comparison. The German version of this article is here.

Pawel Owerczuk
Pawel Owerczuk

AI Agent & RAG Developer

AI Agent & RAG Developer with 10+ years of software engineering experience. Specialized in intelligent AI solutions for enterprises in the DACH & Nordic region.

More about me

Frequently Asked Questions

Yes, if your B2B logic is custom. Customer-specific prices, tiered pricing and separate catalogs are in the core (customer groups, price lists, price rules, sales channels). Company accounts, spending limits, approvals and quotes come from the official, MIT-licensed B2B Starter as separate modules. Invoice payment, reverse charge VAT and e-invoicing remain project work.

An official template from Medusa at github.com/medusajs/b2b-starter: a monorepo with a backend and a Next.js storefront, MIT-licensed, on Medusa 2.21.2 as of 19 October 2026. It ships the modules company, quote and approval. The older repository b2b-starter-medusa is archived and no longer maintained.

Yes, in the core. Every price can carry min_quantity and max_quantity, and price calculation picks the matching tier from the cart quantity. Combined with price rules on customer groups, you get customer-specific tiers. One caveat: a manually set line item price is not recalculated when the quantity changes.

Not out of the box. Medusa has a payment provider module through which providers such as Billie or Mondu can be connected. In my research on 19 October 2026 I found no official, maintained plugin for either, only integration services offered by agencies.

Shopify Plus ships B2B features ready to use and is faster to launch for standard B2B. Medusa pays off when approval flows, pricing logic or ERP integration are specific enough that you would be building custom code anyway, and your team works in TypeScript.