Skip to main content

What the Wholesale Portal Is

The storefront ships an optional wholesale portal at /wholesale — a gated B2B surface for approved trade buyers. It runs on a separate Spree sales channel but shares the same storefront app and Spree backend as the public direct-to-consumer (DTC) store, so a single deployment serves both audiences: the open catalog for shoppers and a login-gated trade catalog with volume pricing for wholesale buyers. The portal is an opt-in addon. It is off by default; a DTC-only storefront never renders any wholesale UI.
The wholesale portal is a reference implementation, not a fixed feature. It demonstrates one shape — a gated B2B surface running alongside an open DTC store — but the same building blocks (channels, surfaces, and access gating) support the whole spectrum. You can invert the default and make the storefront closed or limited: gate the primary DTC channel with login_required or prices_hidden for a members-only or invite-only shop, drop the public catalog entirely and ship a login-first B2B storefront, or run several gated channels with no open surface at all. Treat this page as a worked example to adapt, not a prescription — the storefront is yours to reshape.

Surfaces and Channel Switching

A surface is a distinct sales context backed by its own Spree channel. The storefront models two out of the box — dtc (the public storefront) and wholesale (the trade portal) — but the mechanism is general: any channel on your backend can back its own surface. The surface selects which SDK client a request goes through. A channel-bound client sends the channel code on every request via the X-Spree-Channel header, and the Store API resolves the request against that channel — its catalog, pricing, and access rules. In the SDK this is the channel client option:
Alternatively, a channel-bound publishable key carries the channel server-side: when a key is scoped to a channel on the backend, the API assigns that channel to the request without needing the header. Either path works; the header is the simplest and is what the storefront uses by default. Because each surface has its own client, cart cookies, and cache keys, a customer can hold an open DTC cart and an open wholesale cart at the same time without them mixing. The customer session (JWT) is shared — it’s the same person signing in — so only the cart splits, never the auth. To add your own channel-backed surface, follow the same pattern: create the channel on the backend, add a client bound to its code, and thread the surface through the routes that should target it.

Enabling Wholesale

Wholesale turns on through a single environment variable:
  • SPREE_WHOLESALE_CHANNEL — the enable switch. Set it to the code of a gated channel on your backend (e.g. wholesale). There is no default: when it is unset, the storefront runs DTC-only — the wholesale nav link, footer link, and homepage section are hidden, and every /wholesale route returns 404.
  • SPREE_WHOLESALE_PUBLISHABLE_KEY — optional. The channel header alone selects the channel, so this falls back to SPREE_PUBLISHABLE_KEY. Set it only to bind a channel-scoped publishable key.
The backend must have a channel whose code matches SPREE_WHOLESALE_CHANNEL. Its storefront_access posture decides how much a guest can see — the portal supports both login_required (guests are walled off entirely) and prices_hidden (guests browse the catalog but not prices). See Gating Modes below. Spree installs seed a login_required wholesale channel by default; switching to prices_hidden is a single flag on that same channel — no new channel or seed data.

How Gating Works End to End

Two independent checks gate the portal — one at the channel (how much of the catalog a guest may see), one at the customer (whether a buyer may transact at trade pricing). The channel check is set by the channel’s gating mode; the customer check is always the same. Channel access is enforced by the Store API, not the storefront — the storefront can’t loosen it. Depending on the channel’s storefront_access posture, a guest is either rejected outright (login_required) or served the catalog with money fields returned as null (prices_hidden). This is a general channel feature; see Channels → Storefront Access Gating for how it’s resolved and enforced. Buyer approval. Being logged in isn’t enough; a buyer must be approved. Approval is membership in the Wholesale customer group — an admin adds an applicant to the group to approve them. The storefront reads customer_groups on customers/me and branches accordingly:
  • Guest — sees the sign-in / apply wall (login_required), or the read-only catalog with sign-in-for-pricing prompts (prices_hidden).
  • Signed in, not in the group — sees an application-pending state. Signing in never skips approval: an authenticated but unapproved buyer cannot transact under either mode.
  • Signed in and in the group — gets the full portal: trade catalog, quick order, and the wholesale cart.
Trade pricing is unlocked by volume. The seeded model grants a trade price once a line reaches a minimum quantity of the same item (10 or more per item in the sample data). The applicable volume rule lives on a backend Price List; the storefront surfaces the trade price the API returns for a qualifying quantity.

Gating Modes

A channel’s storefront_access posture is one of three values. The wholesale portal supports the two gated modes; the third describes a fully public channel like DTC. login_required is the strictest posture and the seeded default. The Store API rejects any unauthenticated request to the channel with 401, so a guest can’t read wholesale catalog or pricing at all. The storefront shows the sign-in / apply wall in place of every gated page. prices_hidden opens the catalog to guests while keeping pricing private. The channel doesn’t reject unauthenticated requests, but the API serializes every money field as null for a guest. In the portal, a guest browses the catalog and product pages read-only: each price renders a “sign in for pricing” prompt, and add-to-cart becomes “sign in to order.” Ordering surfaces (cart, quick order) still require sign-in — a guest is redirected to the sign-in flow rather than shown an empty wholesale cart. Approval is unchanged: signing in reveals list prices, and joining the Wholesale group unlocks trade pricing. This mode suits a trade catalog you want discoverable (for SEO or lead generation) without exposing negotiated pricing. public is an ungated channel — the posture the DTC store runs on. It’s listed here for completeness; a wholesale channel would not normally use it. Switching a wholesale channel between the two supported modes is a single change to storefront_access on the backend — set on the channel, or inherited from the store’s default. Nothing in the storefront needs redeploying — the portal reads the channel’s posture at request time and adapts. Login and signup always run through the same approval flow regardless of mode.