---
title: "Sales Channels API"
description: "Create channels, allocate inventory, set audience prices, preview access, pause or archive channels, and manage hosted links."
---

Channels are allocation labels on an Event's inventory. Read
[Sales channels and allocations](/platform/sales-channels/) first for the
feature model; use this page for exact trusted operations. The
[organizer guide](/integrations/private-and-partner-sales/) and
[partner guide](/integrations/partner-selling/) own the two role-specific
workflows.

Two capabilities gate everything here, and neither implies the other:

| Capability | Allows |
|---|---|
| `event:channels:view` | List, allocation map, preview |
| `event:channels:manage` | Create, rename, assign, pause, archive, access links |

`event:block` grants neither. Manage tokens minted before channels existed keep
exactly the capabilities they had.

## List channels

<ApiEndpoint method="GET" path="/v1/events/:key/channels" auth="Secret key, dashboard session, or event:channels:view manage token" />

Add `?includeArchived=1` to include archived channels.

```json
{
  "assignmentVersion": 1,
  "publicSale": {
    "id": "public",
    "name": "Public sale",
    "state": "active",
    "counts": { "allocated": 5, "free": 5, "held": 0, "booked": 0, "blocked": 0, "units": 5 }
  },
  "channels": [
    {
      "id": "chn_9f1c", "name": "Travel Agency A",
      "color": "#7C5CFF", "marker": "diagonal", "externalRef": "ta-a",
      "state": "active", "archiveDestination": null, "accessIntent": "none",
      "priceOverrides": [
        { "categoryKey": "standard", "tierId": null, "price": 18 },
        { "categoryKey": "vip", "tierId": "child", "price": 30 }
      ],
      "pricingVersion": 2,
      "createdAt": 1764600000000, "updatedAt": 1764600000000, "archivedAt": null,
      "counts": { "allocated": 3, "free": 3, "held": 0, "booked": 0, "blocked": 0, "units": 3 },
      "access": { "intent": "none", "hasActiveGrants": false, "lastMintAt": null }
    }
  ]
}
```

`counts` are guest-weighted, so a grouped table contributes its guests;
`units` is the number of inventory rows behind them. `allocated` is
`free + held + booked + blocked`.

`state` is `active`, `paused`, or `archived`.

### The `access` object

`accessIntent` is the sale route you chose; the two derived fields are read from
the grant store, so a row can distinguish "server integration configured" from
"server integration chosen, nothing ever minted".

| Field | Meaning |
|---|---|
| `intent` | `none`, `internal`, `server`, or `hosted_link`; see the matrix below |
| `hasActiveGrants` | At least one unexpired, unrevoked buyer session names this channel |
| `lastMintAt` | Most recent mint for this channel, active or not. `null` if never |

`accessIntent` **is enforced**. Each value opens exactly one way of selling the
allocation and refuses the other three with `409
channel_access_intent_forbids`:

| `accessIntent` | What it opens | What it refuses |
|---|---|---|
| `none` (default) | Nothing. The allocation is a protected reserve | Both mint routes |
| `internal` | Your own staff, through the trusted secret-key inventory routes | Buyer sessions, hosted links |
| `server` | `POST /v1/events/:key/buyer-access-sessions` | Hosted links |
| `hosted_link` | Creating, rotating, and redeeming a hosted access link | Buyer sessions |

A new channel starts at `none`, so nothing can be sold from it until you pick a
route. What a caller may select is still derived from the presented credential.
This field decides which credentials may be issued at all.

## Create a channel

<ApiEndpoint method="POST" path="/v1/events/:key/channels" auth="Secret key, dashboard session, or event:channels:manage manage token" />

| Field | Type | Required | Notes |
|---|---|---:|---|
| `name` | `string` | Yes | Unique per event, case-insensitive. Truncated at 80 characters |
| `color` | `string` | No | Organizer-facing only; never reaches a buyer |
| `marker` | `string` | No | Organizer-facing only |
| `externalRef` | `string` | No | Your own stable id. This is the reconciliation key on reports and webhooks |
| `accessIntent` | `string` | No | `none` (default), `internal`, `server`, `hosted_link` |
| `reason` | `string` | No | Recorded in the channel audit trail |

Returns `201 {"ok": true, "channel": {…}}` with zeroed counts. Creating a
channel does **not** move `assignmentVersion`. No inventory changed hands.

`Public sale` and `public` are reserved names and return `409
channel_name_taken`.

## Update a channel

<ApiEndpoint method="PATCH" path="/v1/events/:key/channels/:channelId" auth="Secret key, dashboard session, or event:channels:manage manage token" />

Send `name`, `accessIntent`, `priceOverrides`, or a combination. A pricing write
must also include `expectedPricingVersion`. Sending none of the three mutable
fields returns `422 channel_name_required`. Returns
`{"ok": true, "channel": {…}}` with the bare record. It has no `counts` or
`access` sub-object.

Neither operation moves `assignmentVersion`.

### Set audience-specific prices

`priceOverrides` is a sparse price book for inventory assigned to this channel.
Omit a category or tier to inherit the event price. Send an empty array to
return the whole channel to event pricing.

```json
{
  "expectedPricingVersion": 2,
  "priceOverrides": [
    { "categoryKey": "standard", "tierId": null, "price": 18 },
    { "categoryKey": "vip", "tierId": "adult", "price": 65 },
    { "categoryKey": "vip", "tierId": "child", "price": 30 }
  ],
  "reason": "Partner contract renewal"
}
```

- Untiered categories require `tierId: null`. A category with ticket tiers
  requires one row for each tier you want to override; a category-level row is
  invalid for it.
- Category and tier ids must exist in the event's current pricing catalogue.
  Prices are major currency units from 0 through 1,000,000, and the list is
  capped at 500 rows.
- `pricingVersion` is independent from `assignmentVersion`. Send the version you
  last read. A stale write changes nothing and returns `409
  channel_pricing_conflict` with the current and expected versions. Re-read and
  ask the operator to review; do not auto-overwrite a colleague's change.
- A changed price affects future selections immediately. Existing holds and
  booked lines retain their immutable unit price and `channelPricingVersion`.

The authoritative server order is: an explicit staff-selected ticket release,
then a channel override, then the active event ticket release, then the event
tier/category price. Ordinary channel-priced sales do not consume an event
release quota. The browser receives only the compact pricing projection for its
granted channels, and the server resolves the same price again when it creates
the hold.

### Switching the sale route while buyers are inside it

Changing `accessIntent` on a channel that has live access is refused with `409
channel_intent_switch_blocked`, so an integration is never cut off silently:

```json
{
  "code": "channel_intent_switch_blocked",
  "details": {
    "from": "hosted_link", "to": "internal",
    "liveAccessLinks": 2, "activeSessions": 7,
    "acknowledgeWith": { "acknowledgeLiveAccess": true }
  }
}
```

Retry with `{"accessIntent": "internal", "acknowledgeLiveAccess": true}` to make the
switch. Buyer sessions already minted **keep working**, as do their holds. They
expire on their normal schedule, and no new session can be minted. Hosted links on the channel are
revoked, because redemption refuses from that moment and a link left listed as
active would advertise a door that no longer opens. The response reports what
happened as `"intentSwitch": {"closedLinks": 2, "keptSessions": 7}`.

Re-declaring the route a channel already has is idempotent and never blocked.
To end live sessions deliberately, revoke them (`DELETE
/v1/events/:key/buyer-access-sessions/:sessionId`) or revoke the link with
`endActiveSessions`.

## Apply an allocation

<ApiEndpoint method="POST" path="/v1/events/:key/channels/assignments" auth="Secret key, dashboard session, or event:channels:manage manage token" />

This operation is **versioned**, **non-destructive**, and reports exactly what it
did.

```json
{
  "targetChannelId": "chn_9f1c",
  "labels": ["A-1", "A-2", "A-3"],
  "assignmentVersion": 1
}
```

| Field | Type | Required | Notes |
|---|---|---:|---|
| `labels` | `string[]` | Yes | De-duplicated server-side. At most **5,000** units per request |
| `assignmentVersion` | `integer` | Yes | The version you last read. Required for concurrency control; see below |
| `targetChannelId` | `string \| null` | No | `null` or `"public"` both mean Public sale. Defaults to Public sale |
| `reason` | `string` | No | Recorded in the audit trail |

### `assignmentVersion` is concurrency control

`assignmentVersion` moves **only** when an assignment changes inventory or when a
channel is archived. A busy on-sale moves the event's
`updatedAt` constantly and never touches this, so your operator's staged edit
does not go stale just because tickets are selling.

Send the version you read. If someone else changed the allocation in between,
**nothing moves** and you get:

```json
{
  "error": "channel_assignment_conflict",
  "code": "channel_assignment_conflict",
  "message": "The channel allocation changed after it was read; refresh and review",
  "details": { "assignmentVersion": 2, "expectedAssignmentVersion": 1 }
}
```

`409`. `details.assignmentVersion` is the server's current value. Re-read it,
show the operator what changed, and retry. Do not retry automatically with the
returned version: that turns a safety mechanism into a silent overwrite of a
colleague's work.

### The six buckets

```json
{
  "ok": true,
  "targetChannelId": "chn_9f1c",
  "assignmentVersion": 2,
  "requested": 6,
  "applied": 3,
  "buckets": {
    "changedFromPublic":     { "count": 2 },
    "movedFromOtherChannel": {
      "count": 1,
      "channels": [ { "channelId": "chn_4a7b", "name": "Sponsor guests", "count": 1 } ]
    },
    "alreadyInTarget": { "count": 1 },
    "skippedHeld":     { "count": 1, "labels": ["A-7"], "truncated": false },
    "skippedBooked":   { "count": 0, "labels": [], "truncated": false },
    "notFound":        { "count": 1, "labels": ["ZZ-9"], "truncated": false }
  }
}
```

Every requested label lands in exactly one bucket.

| Bucket | Meaning |
|---|---|
| `changedFromPublic` | Moved out of Public sale |
| `movedFromOtherChannel` | Moved out of another channel, itemised by source |
| `alreadyInTarget` | Already there. Not an error |
| `skippedHeld` | A buyer is mid-checkout. Not moved |
| `skippedBooked` | Already sold. Not moved |
| `notFound` | Not a label in this event |

- `requested` counts labels **after** de-duplication.
- `applied` = `changedFromPublic.count + movedFromOtherChannel.count`. Only free
  and blocked inventory is ever written.
- **Seats in someone's checkout and seats already sold are never moved.** That is
  the non-destructive guarantee, and it is why an Apply is safe on a live event.
- `movedFromOtherChannel.channels[].name` is `null` when the source channel row
  is gone.
- The three sampled buckets list at most 50 labels; `truncated: true` means the
  `count` exceeded the sample.
- When `applied` is `0`, `assignmentVersion` comes back **unchanged**. An
  all-skipped Apply does not invalidate the version you are holding.

<Aside type="caution" title="Already in the target wins over held and booked">
  Bucket precedence is: not found, already in target, held, booked, from public,
  from another channel. So a *sold* seat that is already in the target channel
  reports as `alreadyInTarget`, not `skippedBooked`. Read `applied` to know what
  changed, not the skip counts.
</Aside>

`422 too_many_units` carries `details: {requested, maximum}` when a request
exceeds 5,000 units.

## Read the allocation map

<ApiEndpoint method="GET" path="/v1/events/:key/channels/allocation" auth="Secret key, dashboard session, or event:channels:view manage token" />

Label to channel, paged by label. Carries no hold, booking, price, or buyer
data.

| Query | Notes |
|---|---|
| `afterLabel` | Exclusive cursor. Pass the previous page's `nextAfterLabel` |
| `limit` | Default 1,000, capped at 5,000 |

```json
{
  "assignmentVersion": 1,
  "allocations": [
    { "label": "A-1", "channelId": "chn_9f1c" },
    { "label": "A-4", "channelId": "public" }
  ],
  "nextAfterLabel": "A-4"
}
```

`nextAfterLabel` is `null` on the last page.

## Preview an audience

<ApiEndpoint method="GET" path="/v1/events/:key/channels/preview" auth="Secret key, dashboard session, or event:channels:view manage token" />

Returns the seat projection a buyer with that exact scope would receive. It uses
the same code path as a real buyer session, is read-only, and mints nothing.

| Query | Notes |
|---|---|
| `channelIds` | Comma-separated. The literal `public` is read as `includePublic`, not as a channel |
| `includePublic` | `1`/`true`. When omitted, defaults to true only if you named no real channels |

```json
{
  "ok": true,
  "audience": { "channelIds": ["chn_9f1c"], "includePublic": false },
  "available": true,
  "seats": { "A-1": "free", "A-3": "blocked" },
  "hidden": [], "closed": [],
  "updatedAt": 1764600000000,
  "assignmentVersion": 1
}
```

A paused, archived, or unknown channel returns the unavailable landing state
rather than its seats, still with `200`:

```json
{
  "ok": true,
  "audience": { "channelIds": ["chn_ghost"], "includePublic": false },
  "available": false,
  "unavailable": [ { "channelId": "chn_ghost", "state": "not_found" } ],
  "assignmentVersion": 1
}
```

Naming more than 20 channels, or an empty scope, returns `422
invalid_channel_scope`.

Use this before a partner goes live: it is the check that the allocation you
applied is the inventory the partner will actually see.

## Pause and resume

<ApiEndpoint method="POST" path="/v1/events/:key/channels/:channelId/pause" auth="Secret key, dashboard session, or event:channels:manage manage token" />
<ApiEndpoint method="POST" path="/v1/events/:key/channels/:channelId/unpause" auth="Secret key, dashboard session, or event:channels:manage manage token" />

Pausing stops new access and new holds. **Existing holds run to their normal
expiry**, so pausing does not strand a buyer mid-checkout. Optional `reason` is
recorded. Returns `{"ok": true, "channel": {…}}`.

Neither moves `assignmentVersion`.

## Archive with a destination

<ApiEndpoint method="POST" path="/v1/events/:key/channels/:channelId/archive" auth="Secret key, dashboard session, or event:channels:manage manage token" />

Archiving closes a channel for good and says where its inventory goes.

| Field | Type | Required | Notes |
|---|---|---:|---|
| `destination` | `string \| null` | Yes, as a key | `null` or `"public"` means Public sale. Otherwise an active channel that is not this one |
| `reason` | `string` | No | Recorded in the audit trail |

Archive takes **no** `assignmentVersion`. It is unconditional and returns the
new value.

```json
{
  "ok": true,
  "channel": {
    "id": "chn_9f1c", "name": "Travel Agency A", "state": "archived",
    "archiveDestination": "public", "archivedAt": 1764600001000
  },
  "assignmentVersion": 2,
  "moved": { "free": 2, "blocked": 0, "booked": 0, "units": 2 },
  "revokedSessions": 1
}
```

- `revokedSessions` counts buyer access sessions cascade-revoked in the same
  operation. An archived channel never leaves live access behind it.
- **Sold seats keep the channel that sold them, forever.** Archiving updates only
  where a seat would return to *if cancelled later*. Nothing is deleted, and the
  [channel report](/server-api/reports) still attributes those sales correctly.

### Archive refuses to strand a buyer

If anyone is mid-checkout on the channel's inventory, archive is blocked and
tells you when to try again:

```json
{
  "error": "channel_archive_blocked_by_holds",
  "code": "channel_archive_blocked_by_holds",
  "message": "This channel still has active holds; archive is blocked until they release or expire",
  "details": {
    "channelId": "chn_9f1c",
    "activeHolds": 1,
    "heldUnits": 1,
    "latestHoldExpiresAt": 1764600180000,
    "retryAfterMs": 179000
  }
}
```

`409`. Wait `retryAfterMs` and retry, or release the holds first.

## Hosted access links

A hosted access link is a shareable URL a buyer opens to obtain a buyer access
session for one channel, without your backend authenticating them individually.
Use one for audiences you cannot authenticate individually, such as a sponsor's
guest list or press allocation. Use a
[buyer access session](/server-api/buyer-access-sessions) when you can
authenticate each buyer.

Hosted access links are available only for **Hosted Ticketing** events, where
the redeemed session continues into SeatLayer checkout and the SeatLayer Order,
ticket, and Door lifecycle. A Platform/SDK event should use a trusted-server-created
buyer access session instead; in that mode your platform owns checkout, payment,
Orders, tickets, refunds, and fulfilment. Link create, list, rotate, and revoke
return `404 not_found` if the event is not Hosted Ticketing. A previously issued
capability returns `401 access_link_invalid` after the event no longer has that
Hosted Ticketing authority.

<ApiEndpoint method="POST" path="/v1/events/:key/channels/:channelId/access-links" auth="Secret key, dashboard session, or event:channels:manage manage token" />

| Field | Type | Default | Bounds |
|---|---|---|---|
| `label` | `string` | `null` | 80 characters |
| `expiresAt` | `integer` (epoch ms) | The event's start, or 7 days out | 60 s to 180 days from now |
| `maxRedemptions` | `integer` | `100` | 1 to 10,000 |
| `maxQuantity` | `integer` | `4` | 1 to 100, guest-weighted across the buyer's holds |
| `sessionTtlSeconds` | `integer` | `1800` | 60 to 43,200 |
| `includePublic` | `boolean` | `false` | — |

`expiresAt` is an absolute timestamp, not a duration.

```json
{
  "link": {
    "id": "alk_5b0e", "channelId": "chn_9f1c", "label": "VIP list Nov 14",
    "includePublic": false, "expiresAt": 1765200000000,
    "maxRedemptions": 100, "redemptions": 0, "maxQuantity": 4,
    "sessionTtlSeconds": 1800,
    "state": "active", "status": "active",
    "createdAt": 1764600000000, "createdBy": "user:usr_123",
    "revokedAt": null, "lastRedeemedAt": null,
    "rotatedFrom": null, "rotatedTo": null
  },
  "url": "https://app.seatlayer.io/a#alc_ZXZ0X2FiYw_7c1e",
  "capability": "alc_ZXZ0X2FiYw_7c1e",
  "revealedOnce": true
}
```

<Aside type="danger" title="This response is the only copy">
  The capability appears here once. SeatLayer stores a hash and **cannot show it
  again**: not in the list, not in support, and not in the database. Persist the
  `url` at the moment you receive it, or you will be rotating.

  It travels in the URL **fragment**, which browsers never send to a server, so
  it stays out of access logs and `Referer` headers.
</Aside>

`state` is `active`, `revoked`, or `rotated`; `status` adds the derived `expired`
and `exhausted`.

### List, rotate, revoke

<ApiEndpoint method="GET" path="/v1/events/:key/channels/:channelId/access-links" auth="Secret key, dashboard session, or event:channels:view manage token" />

Status only: `{"links": [{…, "activeSessions": 0}]}`. No capability or hash.
There is no route that recovers a lost link.

<ApiEndpoint method="POST" path="/v1/events/:key/channels/:channelId/access-links/:linkId/rotate" auth="Secret key, dashboard session, or event:channels:manage manage token" />

Rotation is how you recover a lost link. It issues a successor inheriting every
policy field and ends the predecessor. `endActiveSessions` is a **required
boolean**, and there is no default, because the two answers are very different:

| `endActiveSessions` | Effect |
|---|---|
| `false` | Sessions already minted from the old link keep working until they expire. Use this when the link was merely misplaced |
| `true` | Those sessions are cascade-revoked before the rotation is acknowledged. Use this when the link leaked |

The cascade is scoped to that link, so a session your backend minted directly
for the same channel is untouched. The response adds `previous` and
`endedSessions` to the create shape.

<ApiEndpoint method="DELETE" path="/v1/events/:key/channels/:channelId/access-links/:linkId" auth="Secret key, dashboard session, or event:channels:manage manage token" />

Add `?endActiveSessions=1` to cascade. The default is the gentle branch: the
link stops admitting new buyers, existing sessions expire naturally.

## Errors

| Status | Code | Meaning |
|---:|---|---|
| 404 | `not_found` | Unknown event, channel, or link. Also used for cross-tenant references |
| 409 | `channel_name_taken` | Name already used in this event |
| 409 | `channel_archived` | The channel is archived; nothing may change |
| 409 | `channel_assignment_conflict` | Stale `assignmentVersion`. Refresh and review |
| 409 | `channel_archive_blocked_by_holds` | Active holds. `details` says how many and until when |
| 409 | `channel_assignment_would_drop` | A chart update would remove allocated inventory. Acknowledge and retry |
| 409 | `channel_unavailable` | The channel is not active, so a link cannot be created |
| 409 | `access_link_not_active` | The link is revoked, rotated, or otherwise not live |
| 409 | `too_many_access_links` | 20 live links on this channel |
| 409 | `channel_access_intent_forbids` | The channel's `accessIntent` does not open this sale route. `details` names the channel, its intent, and the route |
| 409 | `channel_intent_switch_blocked` | The channel has live access. Review `details`, then retry with `acknowledgeLiveAccess` |
| 422 | `channel_name_required` | Missing or blank name |
| 422 | `invalid_channel_destination` | Archive destination is missing, unknown, archived, or the channel itself |
| 422 | `invalid_access_intent` | Not one of the four declared access methods |
| 422 | `too_many_units` | More than 5,000 units in one Apply |
| 422 | `labels_required` | No usable labels |
| 422 | `channel_state_invalid` | The requested state transition is not allowed |
| 422 | `invalid_channel_scope` | Empty scope, or more than 20 channels |
| 422 | `assignment_version_required` | `assignmentVersion` absent or not a non-negative integer |
| 422 | `end_active_sessions_required` | Rotation did not say what to do with live sessions |

## Checklist

- [ ] Read `assignmentVersion` immediately before an Apply, and surface conflicts to a human.
- [ ] Treat `alreadyInTarget` as success, not as a skip.
- [ ] Set `externalRef` on every channel you will reconcile against.
- [ ] Preview the audience before a partner goes live.
- [ ] Persist a hosted link's `url` at the moment of creation.
- [ ] Choose `endActiveSessions` explicitly. A misplaced link and a leaked link
      require different handling.
- [ ] Never treat a channel id from a client as authorization.

Next: [buyer access sessions](/server-api/buyer-access-sessions) and the
[private and partner sales tutorial](/integrations/private-and-partner-sales).