---
title: Channels
description: Per-store distribution surfaces — online storefront, POS, marketplace, wholesale — each with its own product catalog and order attribution.
---

## Overview

Channels segment a single [Store](stores.md) into distinct selling surfaces. A channel represents *where* an order originates from — the online storefront, an in-person point-of-sale till, a marketplace integration (Amazon, eBay), a B2B wholesale portal, a mobile app — and *which* subset of the store's products is available there.

Every store ships with one default channel named *Online Store*. You can add more from **Settings → Sales channels** in the admin dashboard.

```mermaid
erDiagram
    Store ||--o{ Channel : "has many"
    Channel ||--o{ ProductPublication : "publishes"
    ProductPublication }o--|| Product : "lists"
    Channel ||--o{ Order : "attributes"
    Channel ||--o{ OrderRoutingRule : "routes by"
    Channel ||--o{ ChannelStockLocation : "serves from"
    Channel }o--o| Catalog : "default assortment"
    Channel ||--o{ ApiKey : "authenticates"

    Channel {
        string name
        string code
        boolean active
        boolean default
    }
```

## Channel Attributes

| Attribute | Description | Example |
|-----------|-------------|---------|
| `name` | Human-readable name, displayed in the admin and reports | `Point of Sale` |
| `code` | URL-safe slug, stable identifier sent via the `X-Spree-Channel` header | `pos` |
| `active` | When `false`, the channel stops accepting orders | `true` |
| `default` | Exactly one channel per store is the default. Used as a fallback when no channel header is present and as the auto-publish target for new products | `true` |
| `storefront_access` | Controls what an anonymous visitor may see: `public`, `prices_hidden`, or `login_required`. Unset inherits the store's setting. See [Storefront Access Gating](#storefront-access-gating) | `login_required` |
| `guest_checkout` | Whether an order may be placed without an account on this channel. Unset inherits the store's setting | `false` |
| `preferred_order_routing_strategy` | Optional per-channel override of the store's [Order Routing](fulfillments.md#order-routing) strategy | `Spree::OrderRouting::Strategy::Rules` |

`code` is normalized to a URL-safe slug on save — `POS` becomes `pos`, `Point of Sale!` becomes `point-of-sale`. Leaving `code` blank derives it from `name`.

## How Channels Work

### Resolution at request time

Every incoming Store API or storefront request resolves to a channel:

1. If the `X-Spree-Channel` header is present, the value is matched against `channels.code` — or `channels.id` when the value looks like a prefixed ID (`ch_…`) — scoped to the current store.
2. Otherwise, the store's default channel is used.

The resolved channel is then available to controllers, models, and serializers throughout the request.

### Selecting a channel from the Store SDK

The Store SDK sends `X-Spree-Channel` on every request when configured. The value can be either the channel `code` (merchant-meaningful, recommended) or the prefixed ID (`ch_…`). `setChannel` is a sticky setter that [mirrors `setLocale` / `setCurrency` / `setCountry`](../sdk/configuration.md).


```typescript Store SDK
// Client-level default
const client = createClient({
  baseUrl: 'https://api.mystore.com',
  publishableKey: 'pk_xxx',
  channel: 'pos',
})

// Sticky setter (mirrors setLocale / setCurrency / setCountry)
client.setChannel('wholesale')

// Per-request override
const products = await client.products.list({}, { channel: 'pos' })
```

```typescript Admin SDK
// admin filters by the channel's code via Ransack (q[channels_code_eq])
const { data: products } = await adminClient.products.list({ channels_code_eq: 'pos' })
```

```bash cURL
curl 'https://api.mystore.com/api/v3/store/products' \
  -H 'X-Spree-API-Key: pk_xxx' \
  -H 'X-Spree-Channel: pos'
```


The Admin API does not consume `X-Spree-Channel` — admin endpoints return data across all channels for the current store. [Filter by channel on the admin side via Ransack](../../api-reference/admin-api/querying.md) (`q[channel_id_eq]=ch_xxx` for orders, `q[channels_id_in][]=ch_xxx` for products).

### Product visibility

A product is visible on a channel only when it has a publication record joining the two. Each publication carries an optional window:

| Publication state | What customers see |
|---|---|
| No publication exists | Product is not on this channel — invisible |
| Publication has no dates set | Live now and indefinitely |
| `published_at` is in the future | Scheduled — not yet visible |
| `unpublished_at` is in the past | Hidden — was visible, now sunset |
| Within the window | Live |

Product status (`draft` / `active` / `archived`) is the **outer gate**: a Draft or Archived product is hidden on every channel regardless of its publication window. The dashboard's Publishing card renders this as a "Not available" badge on every channel row when status isn't `active`.

### Order attribution

Every order is attributed to one channel. The channel is set from the `X-Spree-Channel` header on cart creation, from the merchant's selection on the "New order" form, or defaults to the store's primary channel.

This attribution drives reporting (best-selling by channel, revenue per channel) and per-channel order routing — see [Order Routing](fulfillments.md#order-routing).

### Storefront Access Gating

A channel's `storefront_access` decides what an **anonymous** visitor — a request with no authenticated customer — may see. Logged-in customers are never gated. The posture is one of three values:

| Mode | Guest sees catalog | Guest sees prices | Use case |
|---|---|---|---|
| `public` | Yes | Yes | The default. An open storefront — anyone can browse; guests can also check out when `guest_checkout` is enabled. |
| `prices_hidden` | Yes | No — prices come back `null` | A catalog you want discoverable, with pricing revealed only after sign-in (e.g. a trade catalog for lead generation). |
| `login_required` | No — reads rejected with `401` | No | A fully gated surface — a guest can't read the catalog at all (e.g. a members-only or B2B wholesale portal). |

The gate is enforced by the **Store API**, not the storefront, so a storefront app can't loosen it:

- **`login_required`** — every gated read returns `401` for an unauthenticated request. Endpoints that must stay reachable before sign-in (authentication, password reset, reference data like countries and currencies) are exempt.
- **`prices_hidden`** — reads succeed, but every money field is serialized as `null` for a guest. The storefront renders these as a sign-in prompt rather than a price.

A companion control, **`guest_checkout`**, decides whether an order can be placed without an account on the channel. It's independent of `storefront_access` — a `public` channel can still require accounts, and the two are resolved separately.

#### Store fallback

Both controls fall back to the owning [Store](stores.md) when the channel's own value is unset — the same inheritance pattern as the channel's order-routing strategy. `storefront_access` resolves to the channel value, then the store value, and finally `public` when neither is set. This lets you set a store-wide default (e.g. "all channels require login") and override per channel where needed.


```typescript Admin SDK
// Gate a channel behind sign-in, and require accounts at checkout
await adminClient.channels.update('ch_wholesale', {
  storefront_access: 'login_required',
  guest_checkout: false,
})

// Clear the channel value to inherit the store's default
await adminClient.channels.update('ch_wholesale', {
  storefront_access: null,
})
```

```bash cURL
curl -X PATCH 'https://api.mystore.com/api/v3/admin/channels/ch_wholesale' \
  -H 'X-Spree-API-Key: sk_xxx' \
  -H 'Content-Type: application/json' \
  -d '{ "storefront_access": "login_required", "guest_checkout": false }'
```


Switching a channel between modes takes effect immediately — the posture is resolved per request, so no cache warm-up or redeploy is needed. The Next.js storefront's [wholesale portal](../storefront/nextjs/wholesale.md) is a worked example of a `login_required` / `prices_hidden` channel driving the UI.

## Publishing Products on Channels

### Dashboard

The product edit page has a **Publishing** card with one row per channel the product is on. Click *Manage* to attach or detach channels via checkboxes. Each row expands into a per-channel schedule editor.

Bulk operations from the product list: *Add to sales channels…* and *Remove from sales channels…*.

### Admin API

Three endpoints cover the publishing surface:

| Endpoint | Use case |
|---|---|
| `POST /api/v3/admin/channels/:id/add_products` | Publish one or more products on a specific channel |
| `POST /api/v3/admin/channels/:id/remove_products` | Unpublish products from a specific channel |
| `POST /api/v3/admin/products/bulk_add_to_channels` | Publish many products across many channels in a single request |


```typescript Admin SDK
await adminClient.channels.addProducts('ch_xxx', {
  product_ids: ['prod_aaa', 'prod_bbb'],
  // Optional window — when omitted, existing schedules are preserved
  published_at: '2026-07-01T00:00:00Z',
  unpublished_at: '2026-12-31T23:59:59Z',
})
```

```bash cURL
curl -X POST 'https://api.mystore.com/api/v3/admin/channels/ch_xxx/add_products' \
  -H 'X-Spree-API-Key: sk_xxx' \
  -H 'Content-Type: application/json' \
  -d '{
    "product_ids": ["prod_aaa", "prod_bbb"],
    "published_at": "2026-07-01T00:00:00Z",
    "unpublished_at": "2026-12-31T23:59:59Z"
  }'
```


`channels.addProducts` is idempotent: re-publishing an already-published product is a no-op for its window unless `published_at` / `unpublished_at` are explicitly passed. Cross-store onboarding is allowed when the caller's key has update permission on the product.

For per-product updates, use `PATCH /api/v3/admin/products/:id` with a `product_publications` array:


```typescript Admin SDK
await adminClient.products.update('prod_xxx', {
  product_publications: [
    { channel_id: 'ch_online' },
    { channel_id: 'ch_pos', published_at: '2026-07-01T00:00:00Z' },
  ],
})
```

```bash cURL
curl -X PATCH 'https://api.mystore.com/api/v3/admin/products/prod_xxx' \
  -H 'X-Spree-API-Key: sk_xxx' \
  -H 'Content-Type: application/json' \
  -d '{
    "product_publications": [
      { "channel_id": "ch_online" },
      { "channel_id": "ch_pos", "published_at": "2026-07-01T00:00:00Z" }
    ]
  }'
```


The write contract is **full-set**: the array represents the complete desired state. Channels absent from the payload are detached.

## Auto-Publish Behavior

- **Dashboard** — new products are auto-published on the store's default channel. The merchant can untick channels via the Publishing card post-create.
- **Admin API** — new products are **not** auto-published. The caller supplies `product_publications: [{ channel_id }]` on create, or calls `POST /admin/channels/:id/add_products` afterwards.
- **Sample data** (`spree sample-data`) — all loaded products are explicitly published on the default channel.

## Related Documentation

- [Stores](stores.md) — Channels belong to a store
- [Markets](markets.md) — Different from channels: markets segment geography/currency, channels segment selling surfaces
- [Products](products.md) — Product catalog and publication
- [Order Routing](fulfillments.md#order-routing) — Channels can override the store's routing strategy
- [Store SDK: Products](../sdk/store/products.md) — Channel-scoped product listing and filtering
- [Admin SDK: Resources](../sdk/admin/resources.md) — How `adminClient.channels.addProducts` and other resource methods are structured
- [Wholesale Portal](../storefront/nextjs/wholesale.md) — A gated channel driving the Next.js storefront's B2B surface
