Shopify B2B Catalog API: Company, Catalog, Price List

A Shopify B2B catalog is three Admin API objects wired together: a Catalog whose context is a company location, a PriceList that sets the prices, and a Publication that decides which products show. Against Admin GraphQL 2026-10 you build one with four mutations, companyCreate, priceListCreate, publicationCreate and catalogCreate, then add fixed prices and quantity rules to the price list.

The order you call them in matters, because the child objects take the catalog id and the catalog takes theirs. So does the plan the store is on, because direct assignment to a company location is a Plus feature. And if an ERP is pushing thousands of prices, two limits on the price list mutations set the shape of the sync.

What a B2B catalog is made of

Shopify’s B2B overview models a wholesale customer as a Company with one or more CompanyLocation records (each with billing and shipping addresses, tax settings and payment terms) and CompanyContact records, each tied to a retail Customer. Catalogs attach to the location, not the company. A location can hold several.

The Catalog interface itself is thin: id, title, status (ACTIVE, DRAFT or ARCHIVED), a nullable priceList and a nullable publication. Everything interesting lives on those two children. Both are optional at create time, which is what makes the pricing-only and publication-only patterns further down possible.

Scopes: write_companies (or write_customers) for the company, write_products for catalogs, price lists and quantity rules, and write_publications for the publication.

Which plans get the B2B catalog API?

Since 2 April 2026, every plan has native B2B: companies, payment terms, volume pricing, vaulted cards. The catalog side still splits. The plan features page says Basic, Grow and Advanced can “assign up to 3 active catalogs across all your B2B markets”, and that “Direct catalog assignment isn’t available on the Basic, Grow, or Advanced plans.”

In API terms, CatalogContextInput has two fields, companyLocationIds: [ID!] and marketIds: [ID!]. Plus stores can use the first. Everyone else assigns the catalog to a B2B market and puts the company location in that market’s conditions, a route the 2026-10 reference still labels dev preview as of October 2026.

How do you create the company and its location?

One mutation creates the company, its first location and its first contact. CompanyInput takes name, externalId, note and customerSince; put your ERP’s account number in externalId so the sync has a key that is not Shopify’s. CompanyLocationInput has billingSameAsShipping, so you can send one address.

mutation CreateCompany($input: CompanyCreateInput!) {
  companyCreate(input: $input) {
    company {
      id
      name
      locations(first: 1) { nodes { id name } }
    }
    userErrors { field message code }
  }
}
{
  "input": {
    "company": { "name": "Peak Outdoor Ltd", "externalId": "erp-10442" },
    "companyLocation": {
      "name": "Sheffield warehouse",
      "billingSameAsShipping": true,
      "shippingAddress": {
        "address1": "1 Arundel Gate",
        "city": "Sheffield",
        "zip": "S1 2PN",
        "countryCode": "GB"
      }
    },
    "companyContact": {
      "email": "[email protected]",
      "firstName": "Sam",
      "lastName": "Patel"
    }
  }
}

Only company.name is required. Leave it out and you get a BusinessCustomerUserError with code REQUIRED and the message Name must exist. Keep the location id from the response; it is the only thing the catalog needs.

In what order do you create the price list, publication and catalog?

Price list first, publication second, catalog last. The reason is in the input types: CatalogCreateInput takes optional priceListId and publicationId, while PriceListCreateInput.catalogId and PublicationCreateInput.catalogId are both nullable. Creating the two children unattached and handing their ids to catalogCreate means the catalog goes live complete, with no moment where it is active and has no publication attached.

PriceListCreateInput needs name, currency and parent. The parent is a relative adjustment: type is PERCENTAGE_INCREASE or PERCENTAGE_DECREASE, value is a Float!. The optional settings.compareAtMode is ADJUSTED (compare-at prices move by the same percentage) or NULLIFY (compare-at prices are dropped unless a fixed price sets one). For trade pricing you almost always want NULLIFY; a wholesale buyer does not need to see the retail price struck through.

PublicationCreateInput has defaultState (ALL_PRODUCTS or EMPTY, default EMPTY) and autoPublish, which adds newly created products automatically.

const SHOP = process.env.SHOPIFY_SHOP!; // peak-outdoor.myshopify.com
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN!;

async function admin<T>(query: string, variables: Record<string, unknown>): Promise<T> {
  const res = await fetch(`https://${SHOP}/admin/api/2026-10/graphql.json`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Shopify-Access-Token': TOKEN,
    },
    body: JSON.stringify({ query, variables }),
  });
  const json = (await res.json()) as { data?: T; errors?: unknown };
  if (json.errors || !json.data) throw new Error(JSON.stringify(json.errors));
  return json.data;
}

const CREATE_PRICE_LIST = /* GraphQL */ `
  mutation CreatePriceList($input: PriceListCreateInput!) {
    priceListCreate(input: $input) {
      priceList { id currency }
      userErrors { field message code }
    }
  }
`;

const CREATE_PUBLICATION = /* GraphQL */ `
  mutation CreatePublication($input: PublicationCreateInput!) {
    publicationCreate(input: $input) {
      publication { id autoPublish }
      userErrors { field message code }
    }
  }
`;

const CREATE_CATALOG = /* GraphQL */ `
  mutation CreateCatalog($input: CatalogCreateInput!) {
    catalogCreate(input: $input) {
      catalog { id title status priceList { id } publication { id } }
      userErrors { field message code }
    }
  }
`;

export async function createTradeCatalog(companyLocationId: string, title: string) {
  const pl = await admin<{ priceListCreate: { priceList: { id: string } | null; userErrors: unknown[] } }>(
    CREATE_PRICE_LIST,
    {
      input: {
        name: `${title} prices`,
        currency: 'GBP',
        parent: {
          adjustment: { type: 'PERCENTAGE_DECREASE', value: 25 },
          settings: { compareAtMode: 'NULLIFY' },
        },
      },
    },
  );
  if (!pl.priceListCreate.priceList) throw new Error(JSON.stringify(pl.priceListCreate.userErrors));

  const pub = await admin<{ publicationCreate: { publication: { id: string } | null; userErrors: unknown[] } }>(
    CREATE_PUBLICATION,
    { input: { defaultState: 'ALL_PRODUCTS', autoPublish: true } },
  );
  if (!pub.publicationCreate.publication) throw new Error(JSON.stringify(pub.publicationCreate.userErrors));

  const cat = await admin<{ catalogCreate: { catalog: { id: string } | null; userErrors: unknown[] } }>(
    CREATE_CATALOG,
    {
      input: {
        title,
        status: 'ACTIVE',
        context: { companyLocationIds: [companyLocationId] },
        priceListId: pl.priceListCreate.priceList.id,
        publicationId: pub.publicationCreate.publication.id,
      },
    },
  );
  if (!cat.catalogCreate.catalog) throw new Error(JSON.stringify(cat.catalogCreate.userErrors));
  return cat.catalogCreate.catalog.id;
}

The catalog id that comes back is a gid://shopify/CompanyLocationCatalog/.... Market-scoped catalogs are MarketCatalog; both implement Catalog.

Two warnings from the manage catalogs guide are worth taking literally. A catalog with no publication means “customers logged into their B2B accounts won’t see any products for that location”. And moving a price list to a different catalog with priceListUpdate leaves the old catalog without one, so its buyers silently drop back to store prices.

How do fixed prices and quantity rules attach?

Both are mutations on the price list, not the catalog.

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

mutation AddQuantityRules($priceListId: ID!, $quantityRules: [QuantityRuleInput!]!) {
  quantityRulesAdd(priceListId: $priceListId, quantityRules: $quantityRules) {
    quantityRules { minimum maximum increment }
    userErrors { field message code }
  }
}

PriceListPriceInput is variantId: ID!, price: MoneyInput! and an optional compareAtPrice. Pass amounts as strings ("10.00"), and match the price list’s currency: a EUR amount on a GBP list fails with PRICE_LIST_CURRENCY_MISMATCH. The call is capped at 250 prices per request and is add-or-replace, so re-sending a variant overwrites it. priceListFixedPricesDelete takes 250 variant ids and drops them back to the percentage adjustment. A full catalogue sync is therefore a loop in pages of 250, and you should budget it against the cost-based throttle covered in our Admin GraphQL rate limit post.

QuantityRuleInput is variantId: ID!, minimum: Int!, increment: Int! and a nullable maximum. The validation is strict and the error codes say exactly what is wrong: INCREMENT_IS_GREATER_THAN_MINIMUM, MINIMUM_NOT_MULTIPLE_OF_INCREMENT, MAXIMUM_NOT_MULTIPLE_OF_INCREMENT, MINIMUM_IS_GREATER_THAN_MAXIMUM. A case of six with a minimum order of one case is minimum: 6, increment: 6, maximum: null.

Pricing resolves in a fixed order. A fixed price wins. Without one, a published variant takes the parent percentage adjustment. With no price list on any applicable catalog, the buyer gets the store price converted to the market currency. And if a location has several pricing catalogs that cover the same product, the buyer gets the lowest, which is a trap when a “VIP” catalog and a “standard” catalog overlap by accident.

Why split pricing and publication into separate catalogs?

A merchant with 50 price tiers and 10 product assortments would need 500 full catalogs if each one carried both a price list and a publication. Shopify’s own guidance is to create 50 pricing-only catalogs (a catalogCreate with priceListId and no publicationId) and 10 publication-only catalogs (the reverse), then assign each location the two it needs. Sixty catalogs, same result.

Adding a location to an existing catalog is catalogContextUpdate, with contextsToAdd: { companyLocationIds: [...] }. To see what a location already has, query companyLocation(id:) { catalogs(first: 10) { nodes { id title } } }, or filter the top-level catalogs query with company_location_id:.

What do non-Plus stores do instead?

They assign the catalog to a market rather than a location. marketCreate in the 2026-10 reference takes conditions.companyLocationsCondition, whose input is companyLocationIds: [ID!] or applicationLevel: ALL, and a catalogs list of catalog ids; catalogCreate then uses context: { marketIds: [...] }. The marketCreate reference shows both a per-location and an all-locations example.

The caveat: those examples are labelled as available only in the dev preview as of October 2026. Check the developer changelog before putting it in a production sync. The three-active-catalog cap applies regardless, so design the tiers to fit three; a fourth is a Plus conversation, and our Plus versus Advanced breakdown has the numbers.

What I would build

For a Plus store with an ERP as the source of truth: one pricing-only catalog per price tier, one publication-only catalog per assortment, and companyCreate keyed on externalId. The price sync is a nightly job that pages priceListFixedPricesAdd in blocks of 250 and only sends variants whose price changed. Every trade price list gets compareAtMode: NULLIFY.

For a store on Basic, Grow or Advanced: three market-assigned catalogs, percentage adjustments rather than fixed prices wherever the tiers allow it, and no custom app until the market-type condition leaves dev preview.

What I would not do is reach for PriceList.contextRule; it has been deprecated since API 2023-04 and catalogs replaced it. And I would not model wholesale as customer tags plus a discount Function on a store that now has companies for free.

Whoooop builds Shopify apps and integrations for UK merchants, including B2B pricing syncs from ERP and accounting systems onto companies and catalogs. If your wholesale pricing lives in a spreadsheet and your buyers still email orders, our Shopify development service covers the build.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch