B2B

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Shopify B2B lets a merchant sell to other businesses from the same store that sells to consumers: a buyer logs in as a contact of a company, picks one of the company’s locations, and then sees the products, prices, quantity limits and payment terms that the merchant agreed for that location. This page was written against the Admin GraphQL API and the Storefront API 2026-10, the Customer Account API schema 2026-04 (the newest schema that could be downloaded without a store, shipped in @shopify/hydrogen-react 2026.4.4) and Shopify CLI 5.0.0, and every fact was checked on 2026-10-27. B2B features depend on the plan, and the plan rules changed with the Winter '26 and Spring '26 Editions, so the section Plans, availability and limits lists what is known and where it comes from.

The page covers the four building blocks and the places where they surface: the data model (companies, locations, contacts and roles), catalogs with their price lists, publications and quantity rules, payment terms and deposits, and draft orders with imported orders; then the storefront side (Liquid in themes, and the Customer Account and Storefront APIs for headless), checkout customization with Shopify Functions, events, and a list of the mistakes that cost the most time. It builds on the Admin GraphQL API page (versioning, cost-based throttling, pagination), on Authentication and Access Scopes for the tokens that sign the requests, and on Markets and Internationalization, because a company location is also a market condition and catalogs are shared between Markets and B2B. The running example is Pocket Coffee, a small specialty-coffee shop that opens a wholesale channel: the invented company Cafe Brasa SL (a cafe with a Madrid and a Berlin location) buys 1 kg bags of single-origin beans at a negotiated price, in multiples of two bags, on 30-day terms with a 20 percent deposit.

Every Admin GraphQL, Storefront and Customer Account document on the page was validated against the downloaded schema, and every set of variables was checked against the variable definitions of its operation. The Liquid snippets passed shopify theme check and were rendered with a stand-in engine. The Shopify Function was built and run offline with the CLI’s Function runner. Nothing on this page was executed against a Shopify store: B2B resources need a shop on a plan that supports them and an access token, and no account or credential was created for this page. The section What was run and what was not lists exactly what each check covered.

B2B on Shopify at a glance

Shopify B2B is not a separate product line with its own API. It is a set of objects and settings that sit on top of the ordinary store model, and each layer has its own entry point:

Layer What it does Where you reach it

Companies, locations, contacts

Identify the business customer, its branches and the people who buy for it. A contact is a customer record linked to a company.

Admin GraphQL: Company, CompanyLocation, CompanyContact, CompanyContactRole

Catalogs, price lists, publications

Decide which products a location sees and what it pays, including fixed prices, volume breaks and quantity rules.

Admin GraphQL: CompanyLocationCatalog, PriceList, Publication, QuantityRule, QuantityPriceBreak

Payment terms and deposits

Decide when the company pays: at once, within a number of days, on a date or on fulfillment, with an optional deposit.

Admin GraphQL: PaymentTermsTemplate, BuyerExperienceConfiguration, PaymentTerms; Functions: paymentTermsSet

Orders and draft orders

A merchant or an app quotes and invoices a purchase, or imports history; the order records the purchasing company.

Admin GraphQL: DraftOrder, Order, PurchasingCompany

Storefront

Show the buyer’s location, prices and rules in a theme or a headless storefront.

Liquid objects company, company_location, customer.b2b?; Storefront API @inContext(buyer:); Customer Account API

Checkout

Apply the location’s terms, review rules and validation to the B2B checkout.

Shopify Functions and checkout UI extensions

Source: Apps and B2B

The developer guide describes a typical lifecycle in four steps. A merchant creates a company with its contacts and locations, and may create draft orders for a contact to review. A contact logs in with an account attached to the company, selects products, reviews draft orders and completes checkout. Shopify creates the transaction and the order. An app then uses the Admin API for the integrations around it: order management interfaces, B2B fulfillment, loyalty, or CRM synchronization.

A store can serve consumers and companies together or only companies. The developer guides use the names "blended" and "dedicated" for the two setups; in code the difference is whether the storefront hides prices, the cart and the order buttons from visitors who are not logged in as a B2B buyer. Both setups use the same objects, and the section on Themes shows the Liquid that implements a dedicated store.

Plans, availability and limits

Which B2B capabilities a shop has depends on its plan. The facts below come from the Shopify pricing page, the Shopify Plus Help Center page, the Winter '26 and Spring '26 Editions pages and the 2026-10 API reference. Statements that appear only in the Help Center pages for B2B are not relied on beyond what the developer documentation and the Plus page repeat.

Capability Plans Source and notes

Company profiles, volume pricing and up to three B2B catalogs

Basic, Grow and Advanced (as of the Spring '26 Edition); Plus

Spring '26 Edition item "B2B features on more plans" (company profiles, volume pricing, up to three B2B catalogs and more, at no extra cost). The pricing page lists Shopify B2B with up to 3 catalogs on the lower plans.

Unlimited B2B catalogs

Plus

Pricing page (Plus lists unlimited B2B catalogs) and Plus Help Center page.

Direct assignment of catalogs to companies and company locations

Plus

Plus Help Center page. The API reports it with the catalog error UNPERMITTED_ENTITLEMENTS_DIRECT_CATALOG_ASSIGNMENT and COMPANY_LOCATION_CATALOG_STATUS_PLAN (a company location catalog outside a supported plan can only be archived). The first of the two means that catalogs assigned to company locations cannot be activated on the plan. On the other plans, catalogs reach buyers through B2B markets.

Deposits, partial payments, payment requests per fulfillment

Plus

Plus Help Center page.

Dynamic payment terms and deposits set by a Shopify Function

Plus

Winter '26 Edition; the Functions schema marks paymentTermsSet as available only on stores with the Plus plan.

ACH bank payments at checkout with Shopify Payments, and charging saved accounts from the admin

Plus, United States only

Winter '26 Edition.

Order review rules (checkout submitted as a draft for merchant review) set by a Function

Plus

Winter '26 Edition; Functions schema (orderReviewAdd).

Two things in the documentation do not line up, and a project should resolve them with a development store of the target plan before promising a feature to a merchant. First, the B2B overview guide still states that only development stores, Shopify Plus partners and Shopify affiliates can access the Admin API’s B2B resources, and that Plus partners need a development store created on the Plus plan; the Spring '26 Edition says the same capabilities are available on more plans. Second, the 2026-10 reference says a publication on a catalog is optional (without one, availability follows the sales channel), while the older catalog guide says a B2B catalog without a publication shows no products. The Catalogs section describes both and how to avoid depending on either.

Limit Value Where it applies

B2B catalogs per shop on plans below Plus

3 active (error B2B_CATALOG_LIMIT_REACHED)

catalogCreate, catalogUpdate

Fixed prices per priceListFixedPricesAdd or priceListFixedPricesDelete request

250

Catalog guide

Publishables per publicationUpdate call

50

PublicationUpdateInput in the 2026-10 schema

Deposit percentage

1 to 99 inclusive

DepositInput.percentage, Functions Deposit

Net terms offered by the payment-terms Function

7, 15, 30, 45, 60 or 90 days

Functions schema NetPaymentTerms.dueInDays; the wording says the values may change

Percentage increase of a price list

At most 1000 percent

PriceListUserErrorCode.INVALID_ADJUSTMENT_MAX_VALUE

CompanyLocation.catalogsCount

Capped at 10,000 by default; the count queries take a limit and return a precision

2026-10 schema

Source: PriceList object reference (limits read from the 2026-10 introspection)

B2B does not support purchase options. The overview guide lists subscriptions, pre-orders and try before you buy as unsupported for B2B, and the Storefront API reports a cart line that violates this with the warning code MERCHANDISE_SELLING_PLAN_NOT_APPLICABLE_ON_COMPANY_LOCATION, which was added on 2025-07-30.

Prerequisites

Access scopes

Scope What it unlocks for B2B

read_companies, write_companies

Company, CompanyLocation, CompanyContact, CompanyAddress and the company mutations such as companyCreate and companyLocationUpdate. B2B only. Accepted as an alternative to read_customers and write_customers, which also cover these objects.

read_products, write_products

Catalogs, price lists, quantity rules and publications. The catalog guide lists write_products as the requirement.

read_publications, write_publications

Publication fields and publicationCreate.

read_draft_orders, write_draft_orders

DraftOrder, including the purchasing company and payment terms.

read_orders, write_orders

Order and orderCreate for imports.

read_payment_terms, write_payment_terms

PaymentTerms and PaymentSchedule; also the scope the payment_terms/* webhooks require.

customer_read_companies, customer_write_companies, customer_read_locations, customer_write_locations

Customer Account API: the buyer’s Company and CompanyLocation.

unauthenticated_read_customers, unauthenticated_write_customers

Storefront API access for logged-in buyers, listed in the start-building tutorial.

Customer accounts

B2B only works with the new customer accounts. A store still on legacy customer accounts cannot give buyers a B2B session, and a headless storefront must replace its legacy login code with the Customer Account API authorization flow before it can contextualize requests. Legacy customer accounts are deprecated in favour of new customer accounts, as the Authentication and Access Scopes page explains.

A development store

Create a development store with shopify store create dev or from the Dev Dashboard, add products to it, and give the app the scopes above in shopify.app.toml. The store has to be on a plan with B2B capabilities for the examples to work. This page has no development store behind it, so the app configuration was only validated offline, in the section Events and integrations.

The data model

The B2B objects: a company has locations and contacts; a role assignment joins contact, location and role; a location holds addresses, tax settings, buyer experience settings and catalogs; orders point to a purchasing company

The B2B objects of the Admin API and how they hang together.

Three objects model the customer, and one more joins them:

Object Meaning

Company

The business that buys. Fields include name, note, externalId (the key of the company in an ERP or CRM), customerSince, mainContact, defaultRole, contactRoles, contacts, locations, orders, draftOrders, ordersCount, totalSpent and metafields.

CompanyLocation

A branch of the company, and the object that carries almost all commercial settings: billingAddress, shippingAddress, currency (derived from the shipping address), locale, externalId, note, taxSettings, buyerExperienceConfiguration, catalogs, roleAssignments, staffMemberAssignments, storeCreditAccounts, orders and draftOrders.

CompanyContact

A person who buys on behalf of the company: title, locale, isMainContact, the linked customer, and its roleAssignments. The contact is not a separate login; it points at an ordinary Customer, which is the account that logs in.

CompanyContactRoleAssignment

One row of "this contact has this role at this location". A contact can act only at locations where it holds a role, so creating a contact is not enough to let it order.

CompanyContactRole

A named role defined on the company (id, name, note). Company.contactRoles lists them and Company.defaultRole is the one proposed by default.

Source: Company object reference (fields read from the 2026-10 introspection)

Orders and draft orders refer to all three through the PurchasingEntity union, which is either a Customer or a PurchasingCompany (company, location and contact). A B2B order can be tied to the company, to one location or to one contact. Catalogs, however, attach to a location only, which is why price differences between branches of the same company are modelled with several locations, not several contacts.

Several fields of CompanyLocation are deprecated in 2026-10, and the Storefront API and Customer Account API have smaller versions of the same types:

Deprecated field or mutation Replacement

CompanyLocation.taxExemptions, CompanyLocation.taxRegistrationId

CompanyLocation.taxSettings (taxExempt, taxExemptions, taxRegistrationId), introduced with the tax_exempt field on 2025-01-01

companyLocationAssignTaxExemptions, companyLocationRevokeTaxExemptions, companyLocationCreateTaxRegistration, companyLocationRevokeTaxRegistration

companyLocationTaxSettingsUpdate

CompanyLocation.orderCount, Company.contactCount

ordersCount, contactsCount (both return Count)

CompanyLocation.market

None: "will be removed in a future version"; resolve markets with the Markets API

BuyerExperienceConfiguration.payNowOnly

checkoutToDraft (must be false) together with paymentTermsTemplate (must be null), from which the value is derived

Company.metafieldDefinitions, CompanyLocation.metafieldDefinitions

QueryRoot.metafieldDefinitions

On the storefront side the types are deliberately small. The Storefront API Company has id, name, externalId, timestamps and metafields; CompanyLocation adds locale; CompanyContact has only title and locale; and PurchasingCompany ties them to a cart. The Customer Account API exposes Customer.companyContacts, and from a contact its company, locations, orders, draftOrders and status (DISABLED or ENABLED), and from a location its addresses, tax identifier, buyerExperienceConfiguration, payment instruments and orders.

Companies, contacts and roles

Creating a company

companyCreate creates a company and, in the same call, its first location and first contact. The tutorial stresses that a location must be added to attach information such as tax exemptions, and that a contact must exist before any order or draft order can be created for the company. CompanyCreateInput has three parts: company (name, note, externalId, customerSince), companyLocation (CompanyLocationInput, below) and companyContact (firstName, lastName, email, title, locale, phone).

mutation CompanyCreate($input: CompanyCreateInput!) {
  companyCreate(input: $input) {
    company {
      id
      name
      externalId
      mainContact {
        id
        customer {
          email
        }
      }
      locations(first: 5) {
        nodes {
          id
          name
        }
      }
    }
    userErrors {
      field
      code
      message
    }
  }
}

Variables for Cafe Brasa SL, with the Madrid location as the first location and its purchasing manager as the first contact. The reserved domain example.test stands in for a real address, and the tax ID is a placeholder.

{
  "input": {
    "company": {
      "name": "Cafe Brasa SL",
      "externalId": "ERP-CB-0001",
      "note": "Wholesale account opened after the Madrid tasting"
    },
    "companyLocation": {
      "name": "Cafe Brasa Madrid",
      "externalId": "ERP-CB-0001-MAD",
      "phone": "+34910000001",
      "locale": "es",
      "billingSameAsShipping": true,
      "shippingAddress": {
        "address1": "Calle de Ejemplo 1",
        "city": "Madrid",
        "zip": "28001",
        "countryCode": "ES",
        "recipient": "Goods in"
      },
      "taxRegistrationId": "ESB00000000"
    },
    "companyContact": {
      "firstName": "Marta",
      "lastName": "Ruiz",
      "email": "marta@brasa.example.test",
      "title": "Purchasing"
    }
  }
}

userErrors on all company mutations are BusinessCustomerUserError objects with a code from BusinessCustomerErrorCode: RESOURCE_NOT_FOUND, REQUIRED, NO_INPUT, INVALID_INPUT, INVALID, BLANK, TAKEN (for example a duplicate contact email), TOO_LONG, LIMIT_REACHED, FAILED_TO_DELETE, UNEXPECTED_TYPE and INTERNAL_ERROR. Always select code besides message, because message is meant for people and may be translated.

Mutations for companies, locations and contacts

Mutation Use

companyCreate, companyUpdate, companyDelete, companiesDelete

Create, change (CompanyInput: name, note, externalId, customerSince) and delete companies. companiesDelete takes a list of IDs.

companyContactCreate, companyContactUpdate, companyContactDelete, companyContactsDelete

Manage contacts with CompanyContactInput.

companyAssignCustomerAsContact

Make an existing customer a contact of a company.

companyContactRemoveFromCompany

Remove a contact from the company.

companyAssignMainContact, companyRevokeMainContact

Choose or clear Company.mainContact.

companyContactSendWelcomeEmail

Send the welcome email to the contact (the mutation also takes an optional email).

companyContactAssignRole, companyContactAssignRoles, companyContactRevokeRole, companyContactRevokeRoles

Give or take roles at locations, from the contact side.

companyLocationAssignRoles, companyLocationRevokeRoles

The same assignments from the location side (CompanyLocationRoleAssign).

companyLocationCreate, companyLocationUpdate, companyLocationDelete, companyLocationsDelete

Manage locations.

companyLocationAssignAddress, companyAddressDelete

Set or remove a billing or shipping address (addressTypes).

companyLocationTaxSettingsUpdate

Tax exemptions and registration ID.

companyLocationAssignStaffMembers, companyLocationRemoveStaffMembers

Assign staff members to a location.

Source: Admin GraphQL mutations (names read from the 2026-10 introspection)

Roles

A contact has no permission anywhere until it is assigned a role at a location. Read the available roles from the company, then assign one per location:

query CompanyContactRoles($companyId: ID!) {
  company(id: $companyId) {
    defaultRole {
      id
      name
    }
    contactRoles(first: 10) {
      nodes {
        id
        name
      }
    }
  }
}

mutation ContactAssignRoles($contactId: ID!, $rolesToAssign: [CompanyContactRoleAssign!]!) {
  companyContactAssignRoles(companyContactId: $contactId, rolesToAssign: $rolesToAssign) {
    roleAssignments {
      id
      role {
        name
      }
      companyLocation {
        name
      }
    }
    userErrors {
      field
      code
      message
    }
  }
}

The variables give Marta Ruiz the same role at both locations (the IDs are placeholders; read the real role ID from the first query):

{
  "contactId": "gid://shopify/CompanyContact/1000001",
  "rolesToAssign": [
    {
      "companyContactRoleId": "gid://shopify/CompanyContactRole/1000001",
      "companyLocationId": "gid://shopify/CompanyLocation/1000001"
    },
    {
      "companyContactRoleId": "gid://shopify/CompanyContactRole/1000001",
      "companyLocationId": "gid://shopify/CompanyLocation/1000002"
    }
  ]
}

The schema describes role names such as admin or buyer; read the names and IDs from contactRoles instead of hardcoding them. Importing B2B orders needs the same assignment: the customer must have a role at the location named in the order, or the import returns an error.

Listing and maintaining companies

companies is a normal connection. Its query argument accepts the search syntax of the Admin API, and the sort keys are CREATED_AT, ID, NAME, ORDER_COUNT, SINCE_DATE, TOTAL_SPENT and UPDATED_AT. Count fields return a Count object, not an integer.

query CompaniesPage($after: String) {
  companies(first: 50, after: $after, sortKey: UPDATED_AT, reverse: true, query: "name:Cafe*") {
    pageInfo {
      hasNextPage
      endCursor
    }
    nodes {
      id
      name
      externalId
      ordersCount {
        count
      }
      totalSpent {
        amount
        currencyCode
      }
      locationsCount {
        count
      }
    }
  }
}

mutation CompanyUpdate($companyId: ID!, $input: CompanyInput!) {
  companyUpdate(companyId: $companyId, input: $input) {
    company {
      id
      note
    }
    userErrors {
      field
      code
      message
    }
  }
}

mutation ContactWelcome($contactId: ID!) {
  companyContactSendWelcomeEmail(companyContactId: $contactId) {
    companyContact {
      id
    }
    userErrors {
      field
      code
      message
    }
  }
}

Source: companies query

To export every company with its locations and payment terms, use a bulk query, which avoids paginating and throttling by hand; the Bulk Operations page covers the mechanics. The document below was validated, and it follows the restrictions of bulk queries (at most five connections, at most two levels of nesting):

mutation BulkCompanies {
  bulkOperationRunQuery(
    query: """
    {
      companies {
        edges {
          node {
            id
            name
            externalId
            locations {
              edges {
                node {
                  id
                  name
                  buyerExperienceConfiguration {
                    paymentTermsTemplate {
                      name
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
    """
  ) {
    bulkOperation {
      id
      status
    }
    userErrors {
      field
      message
    }
  }
}

Use externalId on companies and locations as the join key with the ERP or CRM, and keep it stable. A bulk company importer is also a natural fit for an app: the Winter '26 Edition even shows Sidekick generating one from a CSV file and creating companies from a prompt, which is a convenience for merchants, not an API.

Company locations

Adding a location

A company needs one location per place that buys on different terms. CompanyLocationInput takes name, phone, locale, externalId, note, billingAddress and shippingAddress (CompanyAddressInput: address1, address2, city, zip, zoneCode, countryCode, recipient, firstName, lastName, phone), billingSameAsShipping, taxRegistrationId, taxExemptions, taxExempt and buyerExperienceConfiguration. The location’s currency follows its shipping address; without one it follows the shop.

mutation LocationCreate($companyId: ID!, $input: CompanyLocationInput!) {
  companyLocationCreate(companyId: $companyId, input: $input) {
    companyLocation {
      id
      name
      currency
      billingAddress {
        formattedAddress
      }
      shippingAddress {
        formattedAddress
      }
    }
    userErrors {
      field
      code
      message
    }
  }
}

Variables: the Berlin branch ships to Berlin but is invoiced to the Madrid head office.

{
  "companyId": "gid://shopify/Company/1000001",
  "input": {
    "name": "Cafe Brasa Berlin",
    "externalId": "ERP-CB-0001-BER",
    "locale": "en",
    "shippingAddress": {
      "address1": "Beispielstrasse 2",
      "city": "Berlin",
      "zip": "10115",
      "countryCode": "DE"
    },
    "billingAddress": {
      "address1": "Calle de Ejemplo 1",
      "city": "Madrid",
      "zip": "28001",
      "countryCode": "ES"
    }
  }
}

The addresses are not copied onto draft orders automatically. The start-building tutorial points out that billing and shipping information are not populated from the company location, so an app that creates draft orders should read the addresses from the location and pass them in.

Tax settings

Tax exemptions belong to the location, not the company, because two branches may be taxed differently (the guide’s example is a company with branches that follow different billing and taxation rules). Set the exemption before the location places its first order. The mutation takes the exemptions to add and remove and, since the 2025 tax_exempt change, a taxExempt flag and a taxRegistrationId; TaxExemption has 74 values in 2026-10, mostly Canadian and US provincial and state certificates such as CA_BC_RESELLER_EXEMPTION and CA_STATUS_CARD_EXEMPTION.

mutation LocationTax($locationId: ID!) {
  companyLocationTaxSettingsUpdate(companyLocationId: $locationId, taxRegistrationId: "ESB00000000", taxExempt: false) {
    companyLocation {
      id
      taxSettings {
        taxExempt
        taxRegistrationId
        taxExemptions
      }
    }
    userErrors {
      field
      code
      message
    }
  }
}

The Spring '26 Edition adds that B2B taxes are calculated automatically for tax-inclusive pricing, and the tax summary webhook and calculation requests now include customer and company metafields (changelog entry of 2026-04-01), which lets a tax app use company data without extra queries.

Reading everything about a location

One query gathers the settings that decide how a location buys: its terms, its catalogs, who may act for it and its store credit. Store credit accounts can be owned by a company location since 2025-09-15, in the Admin and Customer APIs.

query LocationOverview($id: ID!) {
  companyLocation(id: $id) {
    id
    name
    externalId
    locale
    currency
    company {
      id
      name
    }
    taxSettings {
      taxExempt
      taxRegistrationId
      taxExemptions
    }
    catalogsCount {
      count
    }
    catalogs(first: 10) {
      nodes {
        id
        title
        status
        priceList {
          id
          name
          currency
        }
        publication {
          id
        }
      }
    }
    roleAssignments(first: 10) {
      nodes {
        companyContact {
          id
          customer {
            email
          }
        }
        role {
          name
        }
      }
    }
    storeCreditAccounts(first: 1) {
      nodes {
        id
        balance {
          amount
          currencyCode
        }
      }
    }
  }
}

The location is also a market condition. A market can name specific company locations, or all of them in a region, and Shopify resolves the most specific market first, with the company location ahead of the retail location and the region; the Markets and Internationalization page explains the precedence and lineage rules, and notes that CompanyLocation.market is deprecated.

Catalogs, price lists and publications

Three objects, one idea

A catalog answers two questions for a context: which products can the buyer see, and what do they cost. It does so by pointing at two optional objects. A publication is the set of products and collections visible in the catalog. A price list holds prices for variants: a percentage adjustment of the base price (parent), plus fixed prices for individual variants, plus quantity rules and price breaks. Catalog is an interface with three implementations, chosen by the context:

Type Context Created with

CompanyLocationCatalog

One or more company locations (CatalogContextInput.companyLocationIds)

catalogCreate, catalogUpdate, catalogContextUpdate

MarketCatalog

One or more markets (marketIds); the B2B way to serve whole regions or all locations of a region

Markets API (marketCreate, marketUpdate)

AppCatalog

An app or channel, such as the Online Store

Created by Shopify

Do not confuse these B2B catalogs with Shopify Catalog and the Catalog API, which are unrelated products for product discovery by AI agents. Their changelog entries appear in the same feed, but they have nothing to do with price lists.

CatalogContextInput accepts exactly one kind of context per call (MUST_PROVIDE_EXACTLY_ONE_CONTEXT_TYPE), and catalogContextUpdate must match the existing kind (CONTEXT_DRIVER_MISMATCH). A location that is already in the catalog cannot be added again (CONTEXT_ALREADY_ASSIGNED_TO_CATALOG). The catalog guide recommends managing B2B catalogs through markets when the shop uses the Markets API, instead of linking them to individual company locations.

Step 1: create the catalog

catalogCreate needs a title, a status (ACTIVE, DRAFT or ARCHIVED; the field is required) and a context. The priceListId and publicationId are optional and can be attached later. Pocket Coffee’s wholesale catalog serves both Cafe Brasa locations:

mutation CatalogCreate($input: CatalogCreateInput!) {
  catalogCreate(input: $input) {
    catalog {
      id
      title
      status
      ... on CompanyLocationCatalog {
        companyLocationsCount {
          count
        }
      }
    }
    userErrors {
      field
      code
      message
    }
  }
}
{
  "input": {
    "title": "Wholesale beans 1 kg",
    "status": "ACTIVE",
    "context": {
      "companyLocationIds": [
        "gid://shopify/CompanyLocation/1000001",
        "gid://shopify/CompanyLocation/1000002"
      ]
    }
  }
}

A catalog is created without a price list and without a publication when you leave both out. The 2026-10 reference says a company location catalog that only needs custom pricing should be created with a price list and without a publication, and that without a publication availability is determined by the sales channel. The older catalog guide warns that customers logged in to a B2B account see no products for a location whose catalog has no publication. The two statements describe different generations of the same feature. For a catalog that must restrict products, create a publication explicitly and test with a buyer on the target plan; for a pricing-only catalog, check on a development store that the products you expect are visible before relying on the reference wording.

Step 2: the price list

A price list belongs to one catalog (error CATALOG_TAKEN otherwise) and has a currency. Its parent gives a percentage adjustment of the prices it inherits, and settings.compareAtMode decides what happens to the compare-at price: ADJUSTED applies the same adjustment, NULLIFY removes it. The adjustment type is PERCENTAGE_DECREASE or PERCENTAGE_INCREASE; the schema notes that in PriceListAdjustment.value, positive numbers reduce prices and negative numbers increase them, so read the type and the value together.

mutation PriceListCreate($input: PriceListCreateInput!) {
  priceListCreate(input: $input) {
    priceList {
      id
      name
      currency
      parent {
        adjustment {
          type
          value
        }
        settings {
          compareAtMode
        }
      }
    }
    userErrors {
      field
      code
      message
    }
  }
}
{
  "input": {
    "name": "Wholesale EUR",
    "currency": "EUR",
    "catalogId": "gid://shopify/CompanyLocationCatalog/1000001",
    "parent": {
      "adjustment": {
        "type": "PERCENTAGE_DECREASE",
        "value": 20
      },
      "settings": {
        "compareAtMode": "NULLIFY"
      }
    }
  }
}

If a catalog has no price list, buyers get the initial variant prices converted to the market currency. To move an existing price list to another catalog, use priceListUpdate with a new catalogId; the price list is detached from its previous catalog, and if that catalog depended on it, it becomes a publishing-only catalog on store default prices, so give the old catalog a new price list or accept the change. The PriceList.contextRule field was deprecated in API version 2023-04 and is not in the 2026-10 schema (querying it is rejected, see the checks at the end of the page); migrate any app that still uses it to catalogs.

Step 3: the publication

A publication decides the products. publicationCreate takes the catalogId, a defaultState (an empty publication or one prepopulated with every product) and autoPublish (add new products automatically). publicationUpdate adds and removes products in batches of at most 50.

mutation PublicationCreate($input: PublicationCreateInput!) {
  publicationCreate(input: $input) {
    publication {
      id
      catalog {
        id
        title
      }
    }
    userErrors {
      field
      code
      message
    }
  }
}

mutation PublicationUpdate($id: ID!, $input: PublicationUpdateInput!) {
  publicationUpdate(id: $id, input: $input) {
    publication {
      id
      products(first: 10) {
        nodes {
          id
          title
        }
      }
    }
    userErrors {
      field
      code
      message
    }
  }
}
{
  "input": {
    "catalogId": "gid://shopify/CompanyLocationCatalog/1000001",
    "defaultState": "EMPTY"
  }
}

Changing the publication of a catalog with catalogUpdate leaves the previous publication in the system, orphaned, unless the app deletes it. The guide’s recipe is to read the current publication ID, update the catalog with the new publication, then call publicationDelete on the old one. catalogDelete has a deleteDependentResources argument for removing the price list and publication with the catalog.

mutation CatalogRepublish($id: ID!, $input: CatalogUpdateInput!) {
  catalogUpdate(id: $id, input: $input) {
    catalog {
      id
      publication {
        id
      }
    }
    userErrors {
      field
      code
      message
    }
  }
}

mutation PublicationDelete($id: ID!) {
  publicationDelete(id: $id) {
    deletedId
    userErrors {
      field
      code
      message
    }
  }
}

mutation CatalogDelete($id: ID!) {
  catalogDelete(id: $id, deleteDependentResources: true) {
    deletedId
    userErrors {
      field
      code
      message
    }
  }
}

Step 4: fixed prices

A variant published to the catalog without a fixed price gets the percentage adjustment of the price list. Set a fixed price when the negotiated price is not a clean percentage. priceListFixedPricesAdd is an add-and-replace operation: a variant that already has a fixed price on the list is overwritten, and a request holds at most 250 prices. priceListFixedPricesDelete takes up to 250 variant IDs, after which the adjustment applies again. The list also has priceListFixedPricesUpdate and priceListFixedPricesByProductUpdate for mixed changes.

mutation FixedPricesAdd($priceListId: ID!, $prices: [PriceListPriceInput!]!) {
  priceListFixedPricesAdd(priceListId: $priceListId, prices: $prices) {
    prices {
      variant {
        id
        sku
      }
      price {
        amount
        currencyCode
      }
      originType
    }
    userErrors {
      field
      code
      message
    }
  }
}

Pocket Coffee negotiates 22.00 EUR for the 1 kg Brazil bag (variant PC-BRA-WHL-1KG, list price 29.90 EUR):

{
  "priceListId": "gid://shopify/PriceList/1000001",
  "prices": [
    {
      "variantId": "gid://shopify/ProductVariant/1000101",
      "price": { "amount": "22.00", "currencyCode": "EUR" },
      "compareAtPrice": { "amount": "29.90", "currencyCode": "EUR" }
    }
  ]
}

Step 5: quantity rules and volume pricing

Wholesale usually comes with order-size rules. A QuantityRule has a minimum, an optional maximum and an increment: only quantities divisible by the increment are valid, the minimum must be a multiple of the increment, and the maximum too. A QuantityPriceBreak gives a lower price from a minimum quantity. quantityPricingByVariantUpdate changes fixed prices, rules and breaks in one request, runs the deletions before the additions, and applies nothing if any change fails. Quantity rules and breaks are accepted only for company location catalogs and for catalogs on compatible markets (CATALOG_CONTEXT_DOES_NOT_SUPPORT_QUANTITY_RULES).

mutation QuantityPricing($priceListId: ID!, $input: QuantityPricingByVariantUpdateInput!) {
  quantityPricingByVariantUpdate(priceListId: $priceListId, input: $input) {
    productVariants {
      id
      sku
    }
    userErrors {
      field
      code
      message
    }
  }
}

Cafe Brasa buys the Colombia 1 kg bag (variant PC-COL-WHL-1KG) in multiples of two, at least four and at most a hundred, at 21.50 EUR from ten bags and 20.00 EUR from twenty:

{
  "priceListId": "gid://shopify/PriceList/1000001",
  "input": {
    "pricesToAdd": [],
    "pricesToDeleteByVariantId": [],
    "quantityRulesToAdd": [
      { "variantId": "gid://shopify/ProductVariant/1000102", "minimum": 4, "increment": 2, "maximum": 100 }
    ],
    "quantityRulesToDeleteByVariantId": [],
    "quantityPriceBreaksToAdd": [
      { "variantId": "gid://shopify/ProductVariant/1000102", "minimumQuantity": 10, "price": { "amount": "21.50", "currencyCode": "EUR" } },
      { "variantId": "gid://shopify/ProductVariant/1000102", "minimumQuantity": 20, "price": { "amount": "20.00", "currencyCode": "EUR" } }
    ],
    "quantityPriceBreaksToDelete": []
  }
}

The schema’s own text for the maximum says it "must be lower than or equal to the minimum", which cannot be what is meant: the maximum is the upper bound and must be at least the minimum. Treat the rule as minimum less than or equal to maximum, both divisible by the increment, with the increment less than or equal to the minimum.

Read the result back to confirm what the price list holds. The originType field of a price tells a fixed price (FIXED) from one computed from the adjustment (RELATIVE), and quantity rules report whether they were set explicitly:

query PriceListPrices($id: ID!) {
  priceList(id: $id) {
    name
    fixedPricesCount
    prices(first: 20, originType: FIXED) {
      nodes {
        variant {
          sku
        }
        price {
          amount
          currencyCode
        }
        originType
        quantityPriceBreaks(first: 5) {
          nodes {
            minimumQuantity
            price {
              amount
            }
          }
        }
      }
    }
    quantityRules(first: 20) {
      nodes {
        productVariant {
          sku
        }
        minimum
        maximum
        increment
        originType
      }
    }
  }
}

query B2BCatalogs {
  catalogs(type: COMPANY_LOCATION, first: 25) {
    nodes {
      id
      title
      status
    }
  }
  catalogsCount(type: COMPANY_LOCATION) {
    count
    precision
  }
}

Several catalogs on one location

A company location can be in several catalogs, and the rules for combining them are simple. A buyer sees the products that are published in at least one applicable catalog, and when several catalogs price the same variant, the lowest listed price applies. The developer docs build a scaling pattern on this: keep pricing-only catalogs (one per price list) and publication-only catalogs (one per assortment) and assign several to each location. A merchant with 50 pricing tiers and 10 assortments then manages 60 catalogs instead of 500 full ones. The pattern is shared with Markets, and on plans below Plus the limit of three B2B catalogs applies, so it matters most on Plus.

Two catalogs on the Madrid location: one with a publication and a 20 percent price list, one pricing-only with 30 percent; the buyer sees the union of publications and pays the lowest price, subject to the quantity rule

How visibility, price and quantity rule are resolved for a location with two catalogs.

The model below encodes only the documented rules, so a configuration can be reasoned about before it is sent to a shop. It is a local illustration, not Shopify’s engine: it does not know about rounding, exchange rules or market inheritance, and how price breaks of one catalog combine with the lower price of another is not documented, so it takes the lowest result.

const base = { 'PC-COL-WHL-1KG': 29.9, 'PC-BRA-WHL-1KG': 29.9, 'PC-ETH-WHL-1KG': 33.9 };

const catalogs = [
  {
    title: 'Wholesale beans 1 kg',
    locations: ['Madrid', 'Berlin'],
    publication: ['PC-COL-WHL-1KG', 'PC-BRA-WHL-1KG'],
    priceList: {
      decreasePercent: 20,
      fixed: { 'PC-BRA-WHL-1KG': 22.0 },
      breaks: { 'PC-COL-WHL-1KG': [{ min: 10, price: 21.5 }, { min: 20, price: 20.0 }] },
      rules: { 'PC-COL-WHL-1KG': { minimum: 4, increment: 2, maximum: 100 } },
    },
  },
  {
    title: 'Spring promotion (pricing only)',
    locations: ['Madrid'],
    publication: null,
    priceList: { decreasePercent: 30, fixed: {}, breaks: {}, rules: {} },
  },
];

const round2 = (n) => Math.round(n * 100) / 100;

export function resolve(location, sku, quantity) {
  const applicable = catalogs.filter((c) => c.locations.includes(location));
  const visible = applicable.some((c) => c.publication && c.publication.includes(sku));
  if (!visible) return { sku, location, visible: false };

  const rule = applicable.map((c) => c.priceList.rules[sku]).find(Boolean)
    ?? { minimum: 1, increment: 1, maximum: null };
  const quantityAllowed =
    quantity >= rule.minimum &&
    (rule.maximum === null || quantity <= rule.maximum) &&
    quantity % rule.increment === 0;

  const candidates = applicable.map((c) => {
    const pl = c.priceList;
    let unit = pl.fixed[sku] ?? round2(base[sku] * (1 - pl.decreasePercent / 100));
    for (const b of (pl.breaks[sku] ?? []).sort((x, y) => x.min - y.min)) if (quantity >= b.min) unit = b.price;
    return { catalog: c.title, unit };
  });
  const best = candidates.reduce((a, b) => (b.unit < a.unit ? b : a));
  return { sku, location, visible, quantity, quantityAllowed, candidates, unit: best.unit, from: best.catalog,
           line: round2(best.unit * quantity) };
}

Running it for six requests gave the following results (unit prices in EUR):

Location Variant Qty Allowed Unit price Price comes from

Madrid

PC-COL-WHL-1KG

12

yes

20.93

Spring promotion (catalog A offers 21.50)

Madrid

PC-BRA-WHL-1KG

6

yes

20.93

Spring promotion (catalog A fixed 22.00)

Berlin

PC-COL-WHL-1KG

12

yes

21.50

Wholesale beans 1 kg (break at 10)

Berlin

PC-COL-WHL-1KG

20

yes

20.00

Wholesale beans 1 kg (break at 20)

Berlin

PC-COL-WHL-1KG

5

no (not a multiple of 2)

23.92

Wholesale beans 1 kg (29.90 less 20 percent)

Berlin

PC-ETH-WHL-1KG

6

not visible

n/a

Not in any publication

Errors worth handling

Code Meaning (from the 2026-10 schema)

B2B_CATALOG_LIMIT_REACHED

The maximum number of B2B catalogs for the plan is reached.

UNPERMITTED_ENTITLEMENTS_DIRECT_CATALOG_ASSIGNMENT, COMPANY_LOCATION_CATALOG_STATUS_PLAN

Catalogs assigned to company locations cannot be active on this plan; they can only be archived.

CONTEXT_CATALOG_LIMIT_REACHED

The catalog limit for the context was reached.

CONTEXT_ALREADY_ASSIGNED_TO_CATALOG, MUST_PROVIDE_EXACTLY_ONE_CONTEXT_TYPE, CONTEXT_DRIVER_MISMATCH

The context arguments are wrong for the catalog.

CATALOG_CONTEXT_DOES_NOT_SUPPORT_QUANTITY_RULES, CATALOG_CONTEXT_DOES_NOT_SUPPORT_QUANTITY_PRICE_BREAKS

Quantity rules and breaks only exist on company location catalogs and compatible market catalogs.

PRICE_LIST_LOCKED, CATALOG_CONTEXT_LOCKED

Another change to the same object is in progress; retry later.

CATALOG_TAKEN (price list)

The catalog already has a price list.

INVALID_ADJUSTMENT_MAX_VALUE

A percentage increase above 1000.

Source: CatalogUserError (codes read from the 2026-10 introspection)

Payment terms and deposits

Payment terms say when a company pays. Shopify models them in two places: a template chosen by the merchant and attached to a location as the default for its orders, and an instance of payment terms with payment schedules on an individual order or draft order.

Templates and their types

paymentTermsTemplates lists the templates of the shop, optionally filtered by PaymentTermsType. A template has an id, a name, a translatedName, a description, a type and, for net terms, dueInDays. The template IDs are specific to a shop, so read them instead of copying the ID from a tutorial.

PaymentTermsType Meaning Schedule field required on the order

RECEIPT

Due on receipt

None

NET

Due a number of days after issue

issuedAt on the schedule

FIXED

Due on a given date

dueAt on the schedule

FULFILLMENT

Due when the order is fulfilled

None

UNKNOWN

Type not recognised

n/a

query PaymentTermsTemplates {
  paymentTermsTemplates {
    id
    name
    translatedName
    description
    paymentTermsType
    dueInDays
  }
}

query NetTemplates {
  paymentTermsTemplates(paymentTermsType: NET) {
    id
    name
    dueInDays
  }
}

The default terms of a location

Set the terms through the location’s buyerExperienceConfiguration. Besides paymentTermsTemplateId it holds checkoutToDraft, editableShippingAddress and deposit:

Field Effect

paymentTermsTemplateId

The template applied to B2B orders from the location. With terms set, a buyer can pay later; without them the buyer pays at checkout.

checkoutToDraft

When true, the checkout creates a draft order for merchant review instead of an order. This is how a merchant puts a human approval step in front of fulfilment.

editableShippingAddress

Whether the buyer can use a different shipping address at checkout.

deposit

A percentage (DepositInput.percentage, 1 to 99) of the order total paid at checkout, with the balance on the terms. The Plus Help Center page lists deposits as a Plus capability.

payNowOnly (deprecated)

Derived from the other two; do not write it.

mutation LocationBuyerExperience($locationId: ID!, $input: CompanyLocationUpdateInput!) {
  companyLocationUpdate(companyLocationId: $locationId, input: $input) {
    companyLocation {
      id
      buyerExperienceConfiguration {
        checkoutToDraft
        editableShippingAddress
        paymentTermsTemplate {
          id
          name
          paymentTermsType
          dueInDays
        }
        deposit {
          ... on DepositPercentage {
            percentage
          }
        }
      }
    }
    userErrors {
      field
      code
      message
    }
  }
}

Variables: Madrid pays net 30 (the template ID comes from the query above) with a 20 percent deposit, and does not go through merchant review:

{
  "locationId": "gid://shopify/CompanyLocation/1000001",
  "input": {
    "buyerExperienceConfiguration": {
      "paymentTermsTemplateId": "gid://shopify/PaymentTermsTemplate/4",
      "checkoutToDraft": false,
      "editableShippingAddress": true,
      "deposit": {
        "percentage": 20
      }
    }
  }
}

Terms on one order or draft order

A draft order can carry terms at creation (DraftOrderInput.paymentTerms and deposit), and paymentTermsCreate, paymentTermsUpdate and paymentTermsDelete change terms on an existing order or draft order. paymentTermsCreate takes the referenceId of the order or draft order and PaymentTermsCreateInput with the template ID and the schedules. The result exposes due, overdue and the paymentSchedules with issuedAt, dueAt, totalBalance and balanceDue, which are the fields to poll for accounts-receivable dashboards.

mutation PaymentTermsOnOrder($referenceId: ID!, $attributes: PaymentTermsCreateInput!) {
  paymentTermsCreate(referenceId: $referenceId, paymentTermsAttributes: $attributes) {
    paymentTerms {
      id
      paymentTermsName
      due
      overdue
      paymentSchedules(first: 5) {
        nodes {
          issuedAt
          dueAt
          totalBalance {
            amount
            currencyCode
          }
        }
      }
    }
    userErrors {
      field
      code
      message
    }
  }
}
{
  "referenceId": "gid://shopify/DraftOrder/1000001",
  "attributes": {
    "paymentTermsTemplateId": "gid://shopify/PaymentTermsTemplate/4",
    "paymentSchedules": [
      {
        "issuedAt": "2026-11-02T09:00:00Z"
      }
    ]
  }
}

PaymentTermsCreateUserErrorCode has a single value, PAYMENT_TERMS_CREATION_UNSUCCESSFUL, so the message is the only clue; the usual causes are a template type whose required date is missing from the schedule, or a reference that cannot take terms. The webhook topics payment_terms/create, payment_terms/update and payment_terms/delete fire when terms change and need read_payment_terms.

Setting terms from a Shopify Function

A payment customization Function can set terms per checkout instead of per location, which is how rules such as "net 30 only above 500 EUR" are expressed. On 2025-06-18 the target cart.payment-methods.transform.run gained the paymentTermsSet operation, which defines net, fixed or event-based terms for the order, with an optional deposit. The operation is only available on Plus stores. The three term types have these inputs:

Variant Fields

net

dueInDays (7, 15, 30, 45, 60 or 90), optional issuedAt (the current time when omitted), optional deposit

fixed

dueAt (a date), optional deposit

event

trigger (FULFILLMENT_CREATED or INVOICE_SENT), optional deposit

Returning no operation leaves the checkout alone, and returning paymentTermsSet with no paymentTerms removes terms. The Function below requests net 30 with a 20 percent deposit for company orders of 500 EUR or more, and does nothing for retail buyers or smaller company orders. It reads the buyer’s purchasingCompany from the cart.

query RunInput {
  cart {
    cost {
      subtotalAmount {
        amount
        currencyCode
      }
    }
    buyerIdentity {
      purchasingCompany {
        company {
          name
        }
        location {
          name
        }
      }
    }
  }
}
// @ts-check

const MINIMUM_SUBTOTAL = 500;
const NET_DAYS = 30;
const DEPOSIT_PERCENTAGE = 20;

/**
 * @param {import("../generated/api").RunInput} input
 * @returns {import("../generated/api").CartPaymentMethodsTransformRunResult}
 */
export function run(input) {
  const company = input.cart.buyerIdentity?.purchasingCompany;
  const subtotal = parseFloat(input.cart.cost.subtotalAmount.amount);

  // Retail buyers and small company orders keep the default behaviour: pay now.
  if (!company || subtotal < MINIMUM_SUBTOTAL) {
    return { operations: [] };
  }

  return {
    operations: [
      {
        paymentTermsSet: {
          paymentTerms: {
            net: {
              dueInDays: NET_DAYS,
              deposit: { percentage: DEPOSIT_PERCENTAGE },
            },
          },
        },
      },
    ],
  };
}

The extension file, with the target and the input query, is:

api_version = "2026-07"

[[extensions]]
name = "Pocket Coffee wholesale payment terms"
handle = "b2b-payment-terms"
type = "function"
description = "Net 30 with a 20 percent deposit for company orders of 500 EUR or more"

  [[extensions.targeting]]
  target = "cart.payment-methods.transform.run"
  input_query = "src/run.graphql"
  export = "run"

  [extensions.build]
  command = ""
  path = "dist/function.wasm"

Built with shopify app build --no-input (the JavaScript builder ran javy and produced a 1,762-byte function.wasm) and run three times with shopify app function run. A company order of 1,196.00 EUR returned one operation with net 30 and a 20 percent deposit; the 119.60 EUR company order and a 29.90 EUR retail cart both returned an empty operations list. The runner reported 173,711 instructions for the first run against the 11 million limit. A Function can be built and run offline; deploying it, installing it and selecting it at a real checkout needs a store and was not done.

Two more payment features arrived in the Editions and are not described in the developer reference as API fields: a Flow action that charges vaulted payment methods of B2B customers on fulfillment, due dates or invoicing (Spring '26), and ACH payments with Shopify Payments for US Plus merchants (Winter '26). A changelog entry of 2026-09-15 also makes family_name optional for B2B orders in Payments Apps API requests.

Draft orders, invoices and imported orders

Quote, invoice and convert

Draft orders are the B2B "quote": a merchant or an app prepares an order for a purchasing entity, checks the totals, sends an invoice and, when the company agrees or pays, completes it into an order. The tutorial is a four-call sequence: draftOrderCalculate, draftOrderCreate, draftOrderInvoiceSend and draftOrderComplete. The purchasing company goes in purchasingEntity.purchasingCompany, which needs all three of companyId, companyLocationId and companyContactId. The input also carries poNumber, paymentTerms and deposit, lineItems (by variantId and quantity, or custom items), billingAddress and shippingAddress, tags, note, taxExempt and reserveInventoryUntil.

The calculation previews taxes and totals without saving anything, so use it to show the quote to the merchant first:

mutation DraftCalculate($input: DraftOrderInput!) {
  draftOrderCalculate(input: $input) {
    calculatedDraftOrder {
      totalPriceSet {
        shopMoney {
          amount
          currencyCode
        }
      }
      totalTaxSet {
        shopMoney {
          amount
        }
      }
    }
    userErrors {
      field
      message
    }
  }
}

mutation DraftCreate($input: DraftOrderInput!) {
  draftOrderCreate(input: $input) {
    draftOrder {
      id
      name
      poNumber
      purchasingEntity {
        ... on PurchasingCompany {
          company {
            name
          }
          location {
            name
          }
        }
      }
      paymentTerms {
        paymentTermsName
        paymentTermsType
        dueInDays
      }
      totalPriceSet {
        shopMoney {
          amount
          currencyCode
        }
      }
    }
    userErrors {
      field
      message
    }
  }
}

mutation DraftInvoice($id: ID!) {
  draftOrderInvoiceSend(id: $id) {
    draftOrder {
      id
      status
    }
    userErrors {
      field
      message
    }
  }
}

mutation DraftComplete($id: ID!) {
  draftOrderComplete(id: $id) {
    draftOrder {
      id
      order {
        id
        name
      }
    }
    userErrors {
      field
      message
    }
  }
}

The first Cafe Brasa order, twelve bags of Colombia 1 kg, on the location’s terms (the same variables serve the calculation and the creation):

{
  "input": {
    "purchasingEntity": {
      "purchasingCompany": {
        "companyId": "gid://shopify/Company/1000001",
        "companyLocationId": "gid://shopify/CompanyLocation/1000001",
        "companyContactId": "gid://shopify/CompanyContact/1000001"
      }
    },
    "poNumber": "PO-2026-0412",
    "lineItems": [
      { "variantId": "gid://shopify/ProductVariant/1000102", "quantity": 12 }
    ],
    "paymentTerms": {
      "paymentTermsTemplateId": "gid://shopify/PaymentTermsTemplate/4"
    },
    "note": "First wholesale order",
    "tags": ["wholesale"]
  }
}

The guide’s examples pass a price with originalUnitPriceWithCurrency and a title for a custom line. A line built from variantId takes the price from the catalog of the purchasing location, so prefer it; pass an explicit price only for custom lines. Sending an invoice and completing a B2B draft order work exactly as they do for consumer draft orders. The draft order reference covers the remaining options, such as inventory reservation and payment status.

Importing B2B orders

A merchant moving from another system can import history so that buyers see their full order list in their account. orderCreate accepts a B2B order when the input names both companyLocationId and a customer to associate. The imports have strict prerequisites: the company, location, contact and products must already exist in Shopify, and the customer must hold a role at that location; otherwise the call returns an error. Always send priceSet for each line: when it is omitted the API fills it from the current variant price, which gives wrong history. The input has poNumber for the original purchase order number, processedAt, financialStatus, currency, transactions, sourceName and sourceIdentifier.

mutation ImportB2BOrder($order: OrderCreateOrderInput!) {
  orderCreate(order: $order) {
    order {
      id
      name
      poNumber
      purchasingEntity {
        ... on PurchasingCompany {
          company {
            id
          }
          location {
            id
          }
        }
      }
    }
    userErrors {
      field
      message
    }
  }
}
{
  "order": {
    "companyLocationId": "gid://shopify/CompanyLocation/1000001",
    "customer": { "toAssociate": { "id": "gid://shopify/Customer/1000001" } },
    "poNumber": "LEGACY-7781",
    "processedAt": "2026-03-14T10:00:00Z",
    "financialStatus": "PAID",
    "currency": "EUR",
    "lineItems": [
      {
        "variantId": "gid://shopify/ProductVariant/1000102",
        "quantity": 10,
        "priceSet": { "shopMoney": { "amount": "21.50", "currencyCode": "EUR" } }
      }
    ]
  }
}

Reading B2B orders back is done from the location (or the company or the contact), which avoids searching the order list by customer:

query B2BOrders($locationId: ID!) {
  companyLocation(id: $locationId) {
    orders(first: 20, reverse: true) {
      nodes {
        id
        name
        poNumber
        paymentTerms {
          paymentTermsName
          overdue
        }
        totalPriceSet {
          shopMoney {
            amount
            currencyCode
          }
        }
      }
    }
  }
}

Themes: Liquid objects for B2B

Liquid exposes the buyer’s company through three objects. customer.b2b? is true when the logged-in customer is acting for a company; customer.current_company and customer.current_location are the company and the location the buyer selected; customer.company_available_locations lists every location the buyer can switch to. A company has id, name, external_id, available_locations, available_locations_count and metafields; a company_location has id, name, external_id, current?, url_to_set_as_current, shipping_address, tax_registration_id, company, metafields and store_credit_account.

The theme guide’s pattern is a location picker that appears only for B2B buyers and offers the other locations when there is more than one. The version below is split into two snippets, with a documented parameter block, so a section can reuse them:

{% doc %}
  Lets a B2B buyer see, and change, the company location they are buying for.

  @example
  {% render 'b2b-location-picker' %}
{% enddoc %}

{%- if customer.b2b? -%}
  <div class="b2b-location-picker">
    <p>
      {{ 'b2b.purchasing_for' | t: company: customer.current_company.name, location: customer.current_location.name }}
    </p>

    {%- if customer.company_available_locations.size > 1 -%}
      <ul>
        {%- for location in customer.company_available_locations -%}
          {%- unless location.current? -%}
            <li>
              <a href="{{ location.url_to_set_as_current }}">{{ location.name }}</a>
            </li>
          {%- endunless -%}
        {%- endfor -%}
      </ul>
    {%- endif -%}
  </div>
{%- endif -%}
{% doc %}
  Shows a price to verified B2B buyers only and a login prompt to everyone else.

  @param {number} price - The price to show, in cents.

  @example
  {% render 'b2b-price', price: product.selected_or_first_available_variant.price %}
{% enddoc %}

{%- if customer.b2b? -%}
  <span class="price">{{ price | money }}</span>
{%- else -%}
  <a href="{{ routes.account_login_url }}">{{ 'b2b.log_in_to_see_price' | t }}</a>
{%- endif -%}

The strings live in locales/en.default.json:

{
  "b2b": {
    "purchasing_for": "You are buying for {{ company }}, location {{ location }}.",
    "log_in_to_see_price": "Log in to see the price",
    "delivery_included": "Delivery included for your company."
  }
}

A section can then branch on the company, as in the guide’s example of showing a message to one company only. The section also gives the snippets a place to be rendered, which keeps Theme Check from flagging them as orphaned:

<section class="b2b-wholesale">
  {% render 'b2b-location-picker' %}

  {% if customer.b2b? and customer.current_company.name == 'Cafe Brasa SL' %}
    <p>{{ 'b2b.delivery_included' | t }}</p>
  {% endif %}

  {% render 'b2b-price', price: 2990 %}
</section>

{% schema %}
{
  "name": "Wholesale",
  "settings": [],
  "presets": [{ "name": "Wholesale" }]
}
{% endschema %}

shopify theme check on the Skeleton theme with these three files added reported 42 files inspected with no offenses. Shopify’s Liquid is not rendered offline, so the snippets were also rendered with LiquidJS, with a small stand-in for the t and money filters and the B2B objects supplied as plain data. For a buyer of Cafe Brasa SL at Madrid with two locations the picker printed "You are buying for Cafe Brasa SL, location Cafe Brasa Madrid." and a link to the Berlin location; for a guest it printed nothing, and the price snippet printed the price for the buyer and the login link for the guest. That proves the logic of the templates and not the behaviour of a real theme.

Two practical points follow from the guide. In a dedicated B2B store, hide prices, the cart icon, add-to-cart buttons and other calls to action from visitors who are not B2B buyers, either with the theme editor’s store contextualization settings or with customer.b2b?; in a blended store keep the default prices for consumers. And for a Theme Store theme that must stay eligible for automatic upgrades, do not edit core theme files: hide the existing section or block and add a Custom Liquid block that does the conditional, as the guide advises. Some areas, such as the cart icon in the header, are not individual blocks, so a code change may be unavoidable there.

Pages that show B2B prices must not be cached and served to other buyers, so custom caches (CDN rules, Hydrogen loaders) must treat these responses as private.

Headless storefronts

A B2B buyer’s path through a headless storefront: log in with customer accounts, list company locations, select a location, contextualize queries, create the cart with the location, check out

The buyer’s path from login to a B2B checkout in a headless storefront.

A headless B2B storefront has four jobs, taken from the developer guide: obtain the buyer’s access token, obtain a company location ID, contextualize product queries to get B2B prices, volume prices and quantity rules, and contextualize the cart so that checkout is a B2B checkout. Without contextualization the store’s base prices and publishing are used, and the buyer sees none of the catalogs, payment terms or other B2B setup.

Step 1 and 2: the token and the buyer’s locations

The Customer Account API authorization flow produces an access_token for the customer; this is the customerAccessToken of the later steps. With that token, a Customer Account API query returns the company contacts of the customer and the locations they can buy for. If the result has one location, keep its ID. If it has several, build a location selector, store the chosen ID and redirect there after login. A location is shown with its address and the payment terms it will use, so the buyer knows what the checkout will do.

query BuyerLocations {
  customer {
    id
    displayName
    companyContacts(first: 5) {
      nodes {
        id
        title
        company {
          id
          name
        }
        locations(first: 20) {
          nodes {
            id
            name
            shippingAddress {
              countryCode
              city
            }
            buyerExperienceConfiguration {
              paymentTermsTemplate {
                name
                dueInDays
              }
            }
          }
        }
      }
    }
  }
}

This query was validated against the Customer Account API schema 2026-04 from @shopify/hydrogen-react 2026.4.4, because the schema for 2026-10 is not served for download; the fields it uses are in the 2026-10 reference pages as well.

Step 3: contextualized Storefront API queries

Pass a buyer to the @inContext directive to contextualize any query. BuyerInput has two fields, customerAccessToken (required) and companyLocationId. A contextualized product query returns the price for the buyer’s location, the quantityRule and the quantityPriceBreaks of each variant, which are the fields a quantity selector and a volume table need.

query WholesaleBeans($buyer: BuyerInput) @inContext(buyer: $buyer, country: ES, language: ES) {
  products(first: 5, query: "tag:wholesale") {
    nodes {
      id
      title
      variants(first: 5) {
        nodes {
          id
          sku
          price {
            amount
            currencyCode
          }
          quantityRule {
            minimum
            maximum
            increment
          }
          quantityPriceBreaks(first: 5) {
            nodes {
              minimumQuantity
              price {
                amount
                currencyCode
              }
            }
          }
        }
      }
    }
  }
}
{
  "buyer": {
    "customerAccessToken": "${CUSTOMER_ACCOUNT_ACCESS_TOKEN}",
    "companyLocationId": "gid://shopify/CompanyLocation/1000001"
  }
}

The variable above shows an environment placeholder where a real request carries the buyer’s token. A B2B response depends on the token and the location, so a server that caches responses by URL would serve one buyer’s prices to another; the guide’s caution says to disable caching on routes that return buyer-specific data, and it links to the caching guide of the legacy Hydrogen framework for how to do that. The @inContext directive has other arguments (country, language, channelId, preferredLocationId, visitorConsent); in cart operations buyer and country are ignored, and only language still applies.

Step 4: the buyer identity on the cart

Because a cart ignores @inContext(buyer:), set the buyer on the cart itself. CartBuyerIdentityInput has email, phone, countryCode, customerAccessToken, companyLocationId and preferences. After the call, the cart’s buyerIdentity.purchasingCompany shows the company, location and contact, and the checkoutUrl leads to a B2B checkout.

mutation CreateB2BCart($buyerIdentity: CartBuyerIdentityInput!) {
  cartCreate(input: {buyerIdentity: $buyerIdentity, lines: [{merchandiseId: "gid://shopify/ProductVariant/1000102", quantity: 12}]}) {
    cart {
      id
      checkoutUrl
      buyerIdentity {
        purchasingCompany {
          company {
            name
          }
          location {
            name
          }
        }
      }
      cost {
        subtotalAmount {
          amount
          currencyCode
        }
      }
    }
    userErrors {
      field
      code
      message
    }
    warnings {
      code
      message
    }
  }
}

mutation SwitchLocation($cartId: ID!, $buyerIdentity: CartBuyerIdentityInput!) {
  cartBuyerIdentityUpdate(cartId: $cartId, buyerIdentity: $buyerIdentity) {
    cart {
      id
    }
    userErrors {
      field
      code
      message
    }
    warnings {
      code
      message
    }
  }
}
{
  "buyerIdentity": {
    "customerAccessToken": "${CUSTOMER_ACCOUNT_ACCESS_TOKEN}",
    "companyLocationId": "gid://shopify/CompanyLocation/1000001"
  }
}

Changing the buyer identity of a cart that already has lines can make it invalid. Products not published for the new location are removed, and products that stay but fall under different quantity rules are left unchanged. Reading the cart does not fail, but updating it may, and a buyer who goes to checkout with an invalid cart sees the problems as an error. The codes to handle in userErrors and warnings:

Code Meaning

MISSING_CUSTOMER_ACCESS_TOKEN

The customer access token is required when a company location is set.

INVALID_COMPANY_LOCATION, BUYER_CANNOT_PURCHASE_FOR_COMPANY_LOCATION

The location was not found or is not allowed, or the buyer cannot purchase for it (the second code was added to CartErrorCode on 2025-05-10).

MINIMUM_NOT_MET, MAXIMUM_EXCEEDED, INVALID_INCREMENT

A cart line breaks the variant’s quantity rule.

PRODUCT_UNAVAILABLE_IN_BUYER_LOCATION (warning)

A line is not published for the buyer’s location.

MERCHANDISE_SELLING_PLAN_NOT_APPLICABLE_ON_COMPANY_LOCATION (warning)

Only one-time purchase is available for B2B orders, so a subscription line is flagged.

Source: cartBuyerIdentityUpdate (codes read from the 2026-10 Storefront schema) and New CartErrorCode: BUYER_CANNOT_PURCHASE_FOR_COMPANY_LOCATION

Headless also allows a gated store, where visitors can browse products but see no price and cannot order. Include the price in the query, and render the add-to-cart button, only when there is a valid session with a stored company location ID. The same principle as the Liquid customer.b2b? check applies, enforced in your own code. Hydrogen is a framework-agnostic toolkit as of 2026-10; the Storefront API and Headless page explains how it holds the Storefront and Customer Account clients that these queries run on.

Checkout and Functions

The B2B checkout is the standard Shopify checkout with the location’s settings applied: the terms and deposit from buyerExperienceConfiguration, the quantity rules and catalog prices of the cart, tax exemptions of the location, and, with checkoutToDraft, the creation of a draft order for merchant review in place of an order. Customization goes through the same extension points as any checkout, with B2B hooks in each:

  • Functions receive cart.buyerIdentity.purchasingCompany in their input, as the payment terms Function above shows, so discounts, delivery customizations, payment customizations and validations can depend on the company or the location.

  • Cart and Checkout Validation Functions can validate the billing address and the PO number since API version 2026-04 (changelog of 2026-04-01). They return errors on the field targets $.cart.billingAddress.{field} and $.cart.poNumber, so a rule such as "company orders require a PO number" no longer needs client-side code. The inputs are null outside Cart and Checkout Validation.

  • A Function can also impose a review requirement with the orderReviewAdd operation; the checkout is then submitted as a draft that the merchant approves. The Winter '26 Edition presents this as rules on order value and products, for Plus.

  • Checkout UI extensions can read metafields owned by the COMPANY and COMPANY_LOCATION of a B2B checkout, besides those on the cart, customer, product and shop; owner_type is optional in 2026-10 and becomes required in 2027-01.

  • The Winter '26 Edition also lists checkout and customer account customization per market and for B2B buyers in the theme editor, and pickup in store as a delivery option for B2B.

Events and integrations

Company data changes through the admin, the API and apps, so an integration that mirrors it to an ERP listens to webhooks and reconciles with a bulk query. The topics are:

Topic Fires when

companies/create, companies/update, companies/delete

A company is created, changed or deleted

company_locations/create, company_locations/update, company_locations/delete

A location is created, changed or deleted (including its settings)

company_contacts/create, company_contacts/update, company_contacts/delete

A contact is created, changed or deleted

company_contact_roles/assign, company_contact_roles/revoke

A role is assigned to or revoked from a contact at a location

payment_terms/create, payment_terms/update, payment_terms/delete

Payment terms on an order or draft order change

draft_orders/create, draft_orders/update, draft_orders/delete

A draft order is created, changed or deleted

The company topics need read_customers or read_companies; the payment terms topics need read_payment_terms; the catalog objects have no webhook topics of their own in 2026-10, so poll catalogs or react to the Markets topics.

The app configuration for these subscriptions goes in shopify.app.toml. This one was validated with shopify app build --no-input using a placeholder client ID and the reserved host example.invalid; the build printed pocket-coffee-b2b-app built!. The offline validation does not check topic names, so a mistyped topic still builds, and only a deploy against Shopify finds it.

client_id = "00000000000000000000000000000000"
name = "pocket-coffee-b2b-app"
application_url = "https://example.invalid"
embedded = true

[access_scopes]
scopes = "read_companies,write_companies,read_customers,read_products,write_products,read_publications,write_publications,read_draft_orders,write_draft_orders,read_orders,write_orders,read_payment_terms,write_payment_terms"

[auth]
redirect_urls = ["https://example.invalid/api/auth"]

[webhooks]
api_version = "2026-10"

  [[webhooks.subscriptions]]
  topics = [
    "companies/create",
    "companies/update",
    "company_locations/create",
    "company_locations/update",
    "company_contacts/create",
    "company_contact_roles/assign",
    "company_contact_roles/revoke",
    "payment_terms/create"
  ]
  uri = "/webhooks/b2b"

  [[webhooks.subscriptions]]
  topics = ["app/uninstalled"]
  uri = "/webhooks/app/uninstalled"

  [[webhooks.subscriptions]]
  uri = "/webhooks/customers/data_request"
  compliance_topics = ["customers/data_request"]

  [[webhooks.subscriptions]]
  uri = "/webhooks/customers/redact"
  compliance_topics = ["customers/redact"]

  [[webhooks.subscriptions]]
  uri = "/webhooks/shop/redact"
  compliance_topics = ["shop/redact"]

The same subscriptions can be created over the API, with the topic as an enum value (COMPANIES_CREATE for companies/create):

mutation SubscribeCompanies($uri: String!) {
  webhookSubscriptionCreate(topic: COMPANIES_CREATE, webhookSubscription: {uri: $uri, format: JSON}) {
    webhookSubscription {
      id
      topic
    }
    userErrors {
      field
      message
    }
  }
}

The ecosystem around B2B is mostly apps. The Winter '26 Edition names eleven apps compatible with B2B for quote requests, custom buyer roles and shopping lists, and the Spring '26 Edition says QuickBooks syncs B2B orders, PO numbers and company details and Mailchimp can segment B2B customers. An app that adds those capabilities builds on the objects of this page.

A Pocket Coffee wholesale walkthrough

Putting the parts together for the example, an app or a script does the following, in this order, because each step needs an ID from the one before:

Step Call Result

1

companyCreate with the Madrid location and Marta Ruiz

Company, first location and main contact exist

2

companyLocationCreate for Berlin

Second location with its own shipping address and the Madrid billing address

3

companyContactAssignRoles for both locations

Marta can act at both locations

4

paymentTermsTemplates(paymentTermsType: NET), then companyLocationUpdate with the template, checkoutToDraft: false and a 20 percent deposit

Both locations default to net 30 with a deposit

5

catalogCreate for both locations, then priceListCreate with 20 percent off and publicationCreate with the two 1 kg beans

Wholesale catalog with a price list and a publication

6

priceListFixedPricesAdd (Brazil 22.00) and quantityPricingByVariantUpdate (Colombia: minimum 4, step 2, maximum 100, breaks at 10 and 20)

Negotiated prices and order-size rules

7

Optional: a second pricing-only catalog for a campaign on Madrid

Madrid gets the lowest of two prices

8

draftOrderCalculate, draftOrderCreate, draftOrderInvoiceSend, draftOrderComplete for the first PO

Order with purchasingEntity and net 30 terms

9

Storefront: login, BuyerLocations, @inContext(buyer:), cartCreate with companyLocationId

Cafe Brasa orders on its own from the shop

Mistakes that cost the most time

  • Creating a contact and expecting it to order. Without a role assignment at a location there is no permission; the Storefront cart reports BUYER_CANNOT_PURCHASE_FOR_COMPANY_LOCATION.

  • Giving the app only write_products and write_draft_orders and then wondering why company mutations fail. Company objects need read_companies and write_companies, or read_customers and write_customers.

  • Copying a payment terms template ID from a tutorial. IDs are specific to a shop, so read them with paymentTermsTemplates.

  • Skipping the publication on a catalog and finding that buyers see nothing (older guide) or whatever the sales channel makes available (2026-10 reference). Decide on purpose, and test with a real buyer on a development store of the target plan.

  • Using several catalogs on a location and assuming the first wins. The lowest listed price wins, and a campaign catalog that is cheaper than the negotiated one silently overrides the negotiation.

  • Sending priceListFixedPricesAdd with more than 250 prices, or expecting it to merge: it replaces the fixed price of a variant that already has one.

  • Changing a catalog’s price list or publication and leaving the previous object orphaned. Delete it, or use deleteDependentResources when deleting the catalog.

  • Importing orders without priceSet. The API fills the current variant price and the history is wrong.

  • Rendering buyer-specific data from a shared cache.

  • Customer accounts: B2B does not work with legacy customer accounts. Headless login code must move to the Customer Account API.

  • Building a subscription or pre-order flow for B2B. It is not supported, and the cart returns a warning.

Version history

Date Change

2023-04

PriceList.contextRule deprecated in favour of catalogs; the field is absent from 2026-10

2025-01-01

CompanyLocationTaxSettings introduced with taxExempt; root taxExemptions and taxRegistrationId deprecated

2025-05-10

Storefront API CartErrorCode.BUYER_CANNOT_PURCHASE_FOR_COMPANY_LOCATION

2025-06-18

paymentTermsSet operation for the payment customization Function (Plus)

2025-07-30

Storefront cart warning for subscriptions on company locations

2025-09-15

Store credit accounts can belong to company locations

2025-12-10

Winter '26 Edition: ACH for B2B, store credit and pickup for B2B, dynamic payment terms and deposits, order review rules

2026-04-01

Billing address and PO number in Cart and Checkout Validation Functions; tax summary webhook includes company metafields

2026-06-17

Spring '26 Edition: B2B features on more plans, B2B taxes for tax-inclusive pricing, vaulted payment methods charged through Flow

2026-09-15

family_name optional for B2B orders in Payments Apps API requests

What was run and what was not

Item Result Notes

All Admin GraphQL documents on this page (company, location, catalog, payment terms, draft order, import, webhook and bulk query)

Validated against the Admin 2026-10 schema (introspection, graphql 16.14.2)

Every document was valid. Deliberately wrong documents were rejected: priceList { contextRule } ("Cannot query field contextRule on type PriceList"), catalogCreate without status ("Field CatalogCreateInput.status of required type CatalogStatus! was not provided") and companyLocation { paymentTerms } ("Cannot query field paymentTerms on type CompanyLocation").

Variables of all Admin mutations

Coerced against the variable definitions of each operation: 14 sets, all valid

Catches wrong enum values, missing required fields and wrong types; it does not check that IDs exist.

Storefront documents (buyer-contextualized product query, cartCreate, cartBuyerIdentityUpdate)

Validated against the Storefront 2026-10 schema; the three variable sets are valid

Tokens in the variables are environment placeholders.

Customer Account document

Validated against the Customer Account schema 2026-04 (from @shopify/hydrogen-react 2026.4.4)

The 2026-10 schema could not be downloaded.

Payment terms Function

shopify app build --no-input built it; shopify app function run returned net 30 with a 20 percent deposit for the 1,196.00 EUR company cart and no operation for a 119.60 EUR company cart and a retail cart

Offline with a placeholder app. Deployment, installation and a real checkout were not tested.

shopify.app.toml with the B2B scopes and webhook topics

shopify app build --no-input printed pocket-coffee-b2b-app built!

Topic names and scopes are not validated offline.

Liquid snippets and section

shopify theme check on the Skeleton theme with the three files: 42 files, no offenses; LiquidJS rendering of the picker and the price snippet with stand-in filters

Shows the template logic, not the behaviour of Shopify’s Liquid or of a theme with B2B data.

Catalog resolution model

Run with Node.js for six requests, output tabulated above

A local illustration of documented rules, not Shopify’s pricing engine.

Anything that needs a shop

Not executed

Real company, catalog, price list, publication and draft order calls, plan limits (the three-catalog limit, direct assignment, deposits), B2B checkout, invitation emails, webhooks and the Customer Account login. No account was created and no credential was used.