B2B Development
Business-to-business commerce on Shopify is a rapidly growing segment, and Shopify Plus provides native B2B features that app developers can extend and integrate with. This module covers the B2B data model, company accounts and locations, custom pricing with catalogs, purchase order approval workflows, checkout customization, and payment method integration.
B2B Features in Shopify Plus
Shopify's native B2B functionality is available exclusively on Shopify Plus plans. As an app developer, understanding these features lets you build complementary tools that merchants need for complex wholesale operations.
B2B Data Model Overview
Company
├── Company Locations (shipping/billing addresses)
│ ├── Catalog Assignments (custom pricing)
│ └── Payment Terms (Net 30, Net 60, etc.)
├── Company Contacts (buyer accounts)
│ └── Roles & Permissions
└── Orders (linked to company + location)
B2B and DTC (direct-to-consumer) operate on the same Shopify store. A single store can serve both retail customers and wholesale buyers. The storefront experience adapts based on whether the logged-in customer is associated with a B2B company.
Querying B2B Companies
query GetCompanies($first: Int!, $query: String) {
companies(first: $first, query: $query) {
edges {
node {
id
name
externalId
note
customerSince
orderCount
totalSpent {
amount
currencyCode
}
mainContact {
customer {
firstName
lastName
email
}
}
locations(first: 10) {
edges {
node {
id
name
shippingAddress {
address1
city
province
country
zip
}
catalog {
id
title
}
buyerExperienceConfiguration {
paymentTermsTemplate {
paymentTermsType
dueInDays
}
}
}
}
}
}
}
}
}
Company Accounts and Locations
Creating Companies Programmatically
Apps that import wholesale customers from ERP systems or other platforms need to create companies via the API:
// services/b2b-company-service.js
export async function createCompany(client, companyData) {
const response = await client.query({
data: {
query: `mutation CompanyCreate($input: CompanyCreateInput!) {
companyCreate(input: $input) {
company {
id
name
}
userErrors {
field
message
}
}
}`,
variables: {
input: {
company: {
name: companyData.name,
externalId: companyData.erpId,
note: companyData.notes,
},
companyLocation: {
name: companyData.headquarters.name || 'Headquarters',
shippingAddress: {
address1: companyData.headquarters.address1,
address2: companyData.headquarters.address2,
city: companyData.headquarters.city,
provinceCode: companyData.headquarters.state,
countryCode: companyData.headquarters.country,
zip: companyData.headquarters.zip,
},
buyerExperienceConfiguration: {
paymentTermsTemplateId: companyData.paymentTermsId,
},
},
companyContact: {
customer: {
firstName: companyData.primaryContact.firstName,
lastName: companyData.primaryContact.lastName,
email: companyData.primaryContact.email,
},
},
},
},
},
});
const result = response.body.data.companyCreate;
if (result.userErrors.length > 0) {
throw new Error(
`Failed to create company: ${result.userErrors.map((e) => e.message).join(', ')}`
);
}
return result.company;
}
Managing Multiple Locations
Large B2B customers often have multiple shipping locations, each with different pricing and payment terms:
export async function addCompanyLocation(client, companyId, locationData) {
const response = await client.query({
data: {
query: `mutation CompanyLocationCreate(
$companyId: ID!,
$input: CompanyLocationInput!
) {
companyLocationCreate(companyId: $companyId, input: $input) {
companyLocation {
id
name
}
userErrors {
field
message
}
}
}`,
variables: {
companyId,
input: {
name: locationData.name,
shippingAddress: locationData.address,
buyerExperienceConfiguration: {
paymentTermsTemplateId: locationData.paymentTermsId,
checkoutToDraft: locationData.requiresApproval,
},
},
},
},
});
return response.body.data.companyLocationCreate;
}
Set checkoutToDraft: true on company locations where orders require manager approval. This causes orders from that location to be created as draft orders instead of confirmed orders, enabling your approval workflow.
Custom Pricing and Catalogs
Catalogs are how Shopify manages B2B-specific pricing. Each catalog contains price lists that override the store's default prices for assigned company locations.
Creating a Catalog with Custom Prices
// services/catalog-service.js
export async function createCatalogWithPricing(client, catalogData) {
// Step 1: Create the catalog
const catalogResponse = await client.query({
data: {
query: `mutation CatalogCreate($input: CatalogCreateInput!) {
catalogCreate(input: $input) {
catalog {
id
title
priceList {
id
}
}
userErrors { field message }
}
}`,
variables: {
input: {
title: catalogData.name,
status: 'ACTIVE',
context: {
companyLocationIds: catalogData.locationIds,
},
},
},
},
});
const catalog = catalogResponse.body.data.catalogCreate.catalog;
// Step 2: Add fixed prices to the price list
if (catalogData.fixedPrices?.length > 0) {
await client.query({
data: {
query: `mutation PriceListFixedPricesAdd(
$priceListId: ID!,
$prices: [PriceListPriceInput!]!
) {
priceListFixedPricesAdd(
priceListId: $priceListId,
prices: $prices
) {
prices {
variant { id }
price { amount currencyCode }
}
userErrors { field message }
}
}`,
variables: {
priceListId: catalog.priceList.id,
prices: catalogData.fixedPrices.map((p) => ({
variantId: p.variantId,
price: {
amount: p.price.toString(),
currencyCode: p.currency,
},
compareAtPrice: p.compareAtPrice
? { amount: p.compareAtPrice.toString(), currencyCode: p.currency }
: null,
})),
},
},
});
}
return catalog;
}
Percentage-Based Pricing Adjustments
For simpler wholesale discount structures, use percentage adjustments instead of fixed prices:
export async function setPercentageAdjustment(client, priceListId, percentage) {
const response = await client.query({
data: {
query: `mutation PriceListUpdate($id: ID!, $input: PriceListInput!) {
priceListUpdate(id: $id, input: $input) {
priceList {
id
adjustment {
type
value
}
}
userErrors { field message }
}
}`,
variables: {
id: priceListId,
input: {
adjustment: {
type: 'PERCENTAGE_DECREASE',
value: percentage, // e.g., 20 for 20% off
},
},
},
},
});
return response.body.data.priceListUpdate;
}
Catalog pricing takes precedence over all other pricing mechanisms, including automatic discounts and price rules. When debugging pricing issues for B2B customers, always check catalog assignments first.
Purchase Order Approval Workflows (Winter '26)
The Winter '26 release introduces native support for purchase order approval workflows in B2B. Company locations can be configured with approval rules that require designated approvers to review orders before they are confirmed. This replaces the earlier draft order workaround.
Configuring Approval Rules
// services/approval-workflow.js
export async function configureApprovalWorkflow(client, locationId, rules) {
const response = await client.query({
data: {
query: `mutation CompanyLocationApprovalConfig(
$locationId: ID!,
$rules: [ApprovalRuleInput!]!
) {
companyLocationUpdateApprovalRules(
companyLocationId: $locationId,
rules: $rules
) {
companyLocation {
id
approvalRules {
id
condition
threshold { amount currencyCode }
approvers { customer { email } }
}
}
userErrors { field message }
}
}`,
variables: {
locationId,
rules: rules.map((rule) => ({
condition: rule.condition, // 'ORDER_TOTAL_EXCEEDS'
threshold: {
amount: rule.threshold.toString(),
currencyCode: rule.currency,
},
approverContactIds: rule.approverIds,
})),
},
},
});
return response.body.data.companyLocationUpdateApprovalRules;
}
Building an Approval Dashboard
// routes/app.approvals.jsx
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import {
Page,
Layout,
Card,
DataTable,
Badge,
Button,
ButtonGroup,
} from '@shopify/polaris';
export async function loader({ request }) {
const { admin } = await authenticate.admin(request);
const response = await admin.graphql(`{
draftOrders(first: 50, query: "status:open AND tag:b2b-pending-approval") {
edges {
node {
id
name
createdAt
totalPriceSet {
shopMoney { amount currencyCode }
}
purchasingEntity {
... on PurchasingCompany {
company { name }
location { name }
contact { customer { email } }
}
}
}
}
}
}`);
const { data } = await response.json();
return json({ pendingOrders: data.draftOrders.edges });
}
export default function ApprovalsPage() {
const { pendingOrders } = useLoaderData();
const rows = pendingOrders.map(({ node }) => [
node.name,
node.purchasingEntity?.company?.name || 'N/A',
node.purchasingEntity?.location?.name || 'N/A',
`$${node.totalPriceSet.shopMoney.amount}`,
new Date(node.createdAt).toLocaleDateString(),
<ButtonGroup key={node.id}>
<Button tone="success" onClick={() => approveOrder(node.id)}>
Approve
</Button>
<Button tone="critical" onClick={() => rejectOrder(node.id)}>
Reject
</Button>
</ButtonGroup>,
]);
return (
<Page title="Purchase Order Approvals">
<Layout>
<Layout.Section>
<Card>
<DataTable
columnContentTypes={[
'text', 'text', 'text', 'numeric', 'text', 'text',
]}
headings={[
'Order', 'Company', 'Location', 'Total', 'Date', 'Actions',
]}
rows={rows}
/>
</Card>
</Layout.Section>
</Layout>
</Page>
);
}
B2B Checkout Customization
Shopify Plus merchants can use Checkout UI Extensions to customize the B2B checkout experience.
Adding a PO Number Field
// extensions/checkout-b2b/src/Checkout.jsx
import {
reactExtension,
TextField,
useApplyMetafieldsChange,
useMetafield,
} from '@shopify/ui-extensions-react/checkout';
export default reactExtension(
'purchase.checkout.shipping-option-list.render-after',
() => <PONumberField />
);
function PONumberField() {
const poNumber = useMetafield({
namespace: 'custom',
key: 'po_number',
});
const applyMetafieldsChange = useApplyMetafieldsChange();
const handleChange = (value) => {
applyMetafieldsChange({
type: 'updateMetafield',
namespace: 'custom',
key: 'po_number',
valueType: 'string',
value,
});
};
return (
<TextField
label="Purchase Order Number"
value={poNumber?.value || ''}
onChange={handleChange}
/>
);
}
Enforcing Minimum Order Quantities
// extensions/checkout-b2b/src/MinOrderValidation.jsx
import {
reactExtension,
useBuyerJourneyIntercept,
useCartLines,
Banner,
} from '@shopify/ui-extensions-react/checkout';
export default reactExtension(
'purchase.checkout.block.render',
() => <MinOrderValidation />
);
function MinOrderValidation() {
const cartLines = useCartLines();
const totalQuantity = cartLines.reduce(
(sum, line) => sum + line.quantity,
0
);
const minimumOrder = 24; // Configurable per company
useBuyerJourneyIntercept(({ canBlockProgress }) => {
if (canBlockProgress && totalQuantity < minimumOrder) {
return {
behavior: 'block',
reason: `Minimum order quantity is ${minimumOrder} units`,
errors: [
{
message: `Your cart has ${totalQuantity} items. The minimum order is ${minimumOrder} items.`,
},
],
};
}
return { behavior: 'allow' };
});
if (totalQuantity < minimumOrder) {
return (
<Banner status="warning">
Minimum order is {minimumOrder} units. You currently have{' '}
{totalQuantity}. Please add {minimumOrder - totalQuantity} more items.
</Banner>
);
}
return null;
}
ACH Payment Integration
Many B2B transactions use ACH (Automated Clearing House) bank transfers rather than credit cards. Shopify supports ACH through payment apps and manual payment methods.
Setting Up ACH via Payment Terms
// services/ach-payment-service.js
export async function configureACHPaymentTerms(client, locationId) {
// First, find or create the ACH payment terms template
const response = await client.query({
data: {
query: `query PaymentTermsTemplates {
paymentTermsTemplates {
id
name
paymentTermsType
dueInDays
}
}`,
},
});
const templates = response.body.data.paymentTermsTemplates;
const net30 = templates.find(
(t) => t.paymentTermsType === 'NET' && t.dueInDays === 30
);
// Assign payment terms to the company location
await client.query({
data: {
query: `mutation AssignPaymentTerms(
$locationId: ID!,
$paymentTermsTemplateId: ID!
) {
companyLocationAssignPaymentTerms(
companyLocationId: $locationId,
paymentTermsTemplateId: $paymentTermsTemplateId
) {
companyLocation {
buyerExperienceConfiguration {
paymentTermsTemplate {
paymentTermsType
dueInDays
}
}
}
userErrors { field message }
}
}`,
variables: {
locationId,
paymentTermsTemplateId: net30.id,
},
},
});
}
B2B payment terms (Net 30, Net 60, etc.) create real financial risk for merchants. If your app automates payment term assignments, always include safeguards such as credit limit checks, payment history verification, and merchant approval flows before extending terms to new companies.
B2B Development Checklist
| Feature | Complexity | Shopify Plus Required |
|---|---|---|
| Company creation and management | Medium | Yes |
| Multi-location support | Medium | Yes |
| Catalog-based pricing | High | Yes |
| Purchase order approvals | High | Yes (Winter '26) |
| Checkout customization | High | Yes |
| Payment terms / ACH | Medium | Yes |
| B2B customer portal | High | Yes |
B2B commerce on Shopify is still maturing, and the opportunities for app developers are significant. Merchants migrating from legacy wholesale platforms need apps that bridge the gaps between Shopify's native features and their complex operational requirements. Building expertise in this area now positions you well as Shopify continues to invest heavily in B2B capabilities.