---
title: "Sales channels"
description: "Create sales channels, apply versioned allocations, preview an audience, pause, archive with a destination, and manage hosted access links."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.seatlayer.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Sales channels

Channels are allocation labels on an event's inventory. Read
[sales channels](/platform/sales-channels) first for the model; this page is the
wire reference.

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

**GET /v1/events/:key/channels** — Authentication: 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",
      "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 what you *declared*; the two derived fields are read from the
grant store, so a row can distinguish "server integration configured" from
"server integration declared, nothing ever minted".

| Field | Meaning |
|---|---|
| `intent` | `none`, `internal`, `server`, or `hosted_link` — your declaration, nothing more |
| `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` grants nothing. What a caller may select is derived from the
credential presented, never from this field.

## Create a channel

**POST /v1/events/:key/channels** — Authentication: 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`.

## Rename, or change the declared access method

**PATCH /v1/events/:key/channels/:channelId** — Authentication: Secret key, dashboard session, or event:channels:manage manage token

Send `name`, `accessIntent`, or both. Sending neither returns `422
channel_name_required`. Returns `{"ok": true, "channel": {…}}` with the bare
record — no `counts`, no `access` sub-object.

Neither operation moves `assignmentVersion`.

## Apply an allocation

**POST /v1/events/:key/channels/assignments** — Authentication: Secret key, dashboard session, or event:channels:manage manage token

This is the operation worth reading carefully. It 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. Not optional, and not decoration — 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` is a counter that moves **only** when an assignment lands —
an Apply that changed something, or an archive. 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,
show your 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.

> **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.

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

## Read the allocation map

**GET /v1/events/:key/channels/allocation** — Authentication: 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

**GET /v1/events/:key/channels/preview** — Authentication: Secret key, dashboard session, or event:channels:view manage token

Returns the seat projection a buyer with that exact scope would receive — the
same code path a real buyer session uses, not an approximation. Read-only, and
it 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 answers with the real unavailable landing
state rather than its seats — still `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

**POST /v1/events/:key/channels/:channelId/pause** — Authentication: Secret key, dashboard session, or event:channels:manage manage token
**POST /v1/events/:key/channels/:channelId/unpause** — Authentication: Secret key, dashboard session, or event:channels:manage manage token

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

Neither moves `assignmentVersion`.

## Archive with a destination

**POST /v1/events/:key/channels/:channelId/archive** — Authentication: 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 when you cannot authenticate each buyer yourself — a sponsor's guest
list, a press allocation — and a
[buyer access session](/server-api/buyer-access-sessions) when you can.

**POST /v1/events/:key/channels/:channelId/access-links** — Authentication: 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
}
```

> **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, 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.

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

### List, rotate, revoke

**GET /v1/events/:key/channels/:channelId/access-links** — Authentication: Secret key, dashboard session, or event:channels:view manage token

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

**POST /v1/events/:key/channels/:channelId/access-links/:linkId/rotate** — Authentication: 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** — 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.

**DELETE /v1/events/:key/channels/:channelId/access-links/:linkId** — Authentication: 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 |
| 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` deliberately — misplaced and leaked are different.
- [ ] 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).

Source: https://docs.seatlayer.io/server-api/channels/index.mdx
