B2B Integration Pattern
How a server-side Maho B2B module integrates with the headless storefront. Written once, followed by every module so the storefront doesn't need to know about each module by name.
The pattern generalises the plugin architecture already used for payments (see src/plugins/stripe/, src/plugins/braintree/). For payments the question is "is Stripe installed on the backend, and what's its publishable key?". For B2B the question is "is B2B Access installed, and what does it say about the current caller and each product?". Same shape.
Contract
A B2B module ships four things.
1. A discovery endpoint
GET /api/rest/v2/b2b/{module}/config returns the effective admin config (after inheritance and store-scope resolution) as a JSON object. If the module isn't installed the endpoint 404s. That's how the storefront finds out the module exists.
Example response for b2b/access/config:
{
"enabled": true,
"hidePrice": true,
"blockPurchase": true,
"requireLogin": false,
"loginMessage": "Log in to see pricing.",
"hiddenPriceMessage": "Log in to see pricing.",
"activationMatrix": {
"byCustomerGroup": true,
"customerGroups": [0],
"byCategory": false,
"categoryIds": []
}
}Only the surface the storefront needs. Admin-only details (email templates, internal IDs) stay off the wire.
2. Per-request annotations on data DTOs
The catalog API's product DTO already ships fields like sku, price, finalPrice. A B2B module adds a namespaced flag block that reflects its decision for the current caller:
interface Product {
// ...existing fields...
gateFlags?: {
requiresLogin: boolean; // redirect guests off this page
hidePrice: boolean; // omit price → storefront shows a login prompt
canCheckout: boolean; // suppress add-to-cart / block cart mutations
};
}When hidePrice: true, the API also omits price / finalPrice / specialPrice / minimalPrice from the DTO before response. That's the enforcement — the storefront can't render what the API doesn't ship. A crafted request can't get the price back; the module refuses at the API layer, not the JS layer.
The module wires this by observing api_product_dto_build (dispatched by Mage/Catalog/Api/ProductProvider.php on both listing and detail hydration). No core patch required.
Future modules add their own flag blocks under their own namespace: myPrice for per-company pricing, orderApprovalRequired for order approval, quoteAvailable for RFQ, etc.
3. Cache-key changes
Once catalog responses vary by caller, the API sends:
Vary: Authorization
Cache-Control: private, max-age=60Not one CDN response for everyone, but N responses (one per customer group + one for guests). Still cacheable, just wider. Storefront-side KV cache splits the same way.
4. (Optional) A client-side script
If the module needs interactivity on the storefront (e.g. re-fetching product data after login so the price appears without a full reload), it ships a small script at /plugins/{module}.js.
Not every B2B module needs one; static gates are handled entirely by the API annotations + SSR-time template checks. Order approval, quotes, and on-account will need one because they mutate cart / checkout state.
Storefront wiring
When a module ships those four things, the storefront needs three tiny additions.
1. src/plugins/{module}/sync.ts
Mirror src/plugins/stripe/sync.ts. Probe the discovery endpoint. If 200, add a plugin manifest to the store config's extensions.b2bPlugins array:
config.extensions.b2bPlugins.push({
code: 'b2b-access',
script: '/plugins/b2b-access.js', // omit if the module has no client script
config: {
hiddenPriceMessage: cfg.hiddenPriceMessage,
loginMessage: cfg.loginMessage,
},
});The sync is idempotent and safe — 404 means the plugin doesn't register, so a store without the module behaves as if the plugin isn't there.
2. Template checks
The layout templates (Product.tsx, LayoutMasonry.tsx, InfoPanelCompact.tsx, ProductCard.tsx, category grids, etc.) read product.gateFlags at render time:
{product.gateFlags?.hidePrice
? <a class="text-sm underline" href="/login">Log in to see pricing</a>
: <span class="price">{formatPrice(product.finalPrice, currency)}</span>}Same for the add-to-cart button (!gateFlags.canCheckout → suppress) and category-level login walls (gateFlags.requiresLogin → redirect).
Because the API strips price when hidePrice is true, the check is belt-and-braces — but writing it explicitly makes the storefront author's intent legible.
3. public/plugins/{module}.js (if the module ships one)
Mounted at runtime from the plugin registry. Does whatever the module needs — for B2B Access, refresh the current product data after a successful login so prices appear without a full reload.
What DOESN'T change
- The storefront core has no knowledge of any individual B2B module. Adding a new module never touches
src/index.tsx,src/templates/*, orsrc/api-client.ts(beyond the type declaration ofgateFlagson Product). One new file per module insrc/plugins/. - Enforcement stays on the server. The storefront hides the price because the API omits it, not because of a client-side rule. A compromised or misconfigured storefront cannot leak protected data.
- CDN caching stays viable. The
Vary: Authorizationwidening splits responses per-group, not per-user; realistic group counts are small (guest + 3–5 B2B tiers).
What changes in core (once, then reusable)
- The
ProductDTO grows an optionalgateFlagsfield — additive, no breakage for callers that don't consume it. - The catalog API resource declares
Vary: Authorizationon its response headers.
That's the whole core surface. Every module after B2B Access reuses it.
Applying the pattern to a new module
Recipe. Take per-company pricing as the example.
- Data-model side: the module already has its own price-list tables.
- DTO annotation: add a
myPricefield to the Product DTO viaapi_product_dto_build. Populate from the module's resolver keyed on the caller's company (ornullif no per-company price applies). - Discovery endpoint:
GET /api/rest/v2/b2b/pricing/configreturns whether the module is enabled, and any UX config (rounding, currency display preferences). - Storefront plugin:
src/plugins/company-pricing/sync.tsprobes the endpoint, registers a plugin entry. - Templates: where the price renders, prefer
product.myPriceoverproduct.finalPricewhen present. That's a one-line change.
Total surface: one new module + one new file in src/plugins/. No changes to templates unless the module needs new UI conventions.
Reference implementation
B2B Access is the reference. When the shape below lands (see the B2B Suite reference), every future B2B module can copy its directory structure and swap the flag names.
- Backend:
mageaustralia/maho-module-b2b-accessextends the Product DTO, ships the/b2b/access/configendpoint, dispatches the observer. - Storefront:
src/plugins/b2b-access/sync.ts+public/plugins/b2b-access.js. - Templates check
product.gateFlagsinline.
The docs page for B2B Suite tracks the current implementation status.