Channels are allocation labels on an Event’s inventory. Read Sales channels and allocations first for the feature model; use this page for exact trusted operations. The organizer guide and partner guide 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
/v1/events/:key/channelsSecret key, dashboard session, or event:channels:view manage tokenAdd ?includeArchived=1 to include archived channels.
{
"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
/v1/events/:key/channelsSecret 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
/v1/events/:key/channels/:channelIdSecret key, dashboard session, or event:channels:manage manage tokenSend 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.
{
"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.
pricingVersionis independent fromassignmentVersion. Send the version you last read. A stale write changes nothing and returns409 channel_pricing_conflictwith 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:
{
"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
/v1/events/:key/channels/assignmentsSecret key, dashboard session, or event:channels:manage manage tokenThis operation is versioned, non-destructive, and reports exactly what it did.
{
"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:
{
"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
{
"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 |
requestedcounts 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[].nameisnullwhen the source channel row is gone.- The three sampled buckets list at most 50 labels;
truncated: truemeans thecountexceeded the sample. - When
appliedis0,assignmentVersioncomes back unchanged. An all-skipped Apply does not invalidate the version you are holding.
422 too_many_units carries details: {requested, maximum} when a request
exceeds 5,000 units.
Read the allocation map
/v1/events/:key/channels/allocationSecret key, dashboard session, or event:channels:view manage tokenLabel 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 |
{
"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
/v1/events/:key/channels/previewSecret key, dashboard session, or event:channels:view manage tokenReturns 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 |
{
"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:
{
"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
/v1/events/:key/channels/:channelId/pauseSecret key, dashboard session, or event:channels:manage manage token/v1/events/:key/channels/:channelId/unpauseSecret key, dashboard session, or event:channels:manage manage tokenPausing 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
/v1/events/:key/channels/:channelId/archiveSecret key, dashboard session, or event:channels:manage manage tokenArchiving 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.
{
"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
}revokedSessionscounts 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 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:
{
"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 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.
/v1/events/:key/channels/:channelId/access-linksSecret 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.
{
"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
}state is active, revoked, or rotated; status adds the derived expired
and exhausted.
List, rotate, revoke
/v1/events/:key/channels/:channelId/access-linksSecret key, dashboard session, or event:channels:view manage tokenStatus only: {"links": [{…, "activeSessions": 0}]}. No capability or hash.
There is no route that recovers a lost link.
/v1/events/:key/channels/:channelId/access-links/:linkId/rotateSecret key, dashboard session, or event:channels:manage manage tokenRotation 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.
/v1/events/:key/channels/:channelId/access-links/:linkIdSecret key, dashboard session, or event:channels:manage manage tokenAdd ?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
assignmentVersionimmediately before an Apply, and surface conflicts to a human. - Treat
alreadyInTargetas success, not as a skip. - Set
externalRefon every channel you will reconcile against. - Preview the audience before a partner goes live.
- Persist a hosted link’s
urlat the moment of creation. - Choose
endActiveSessionsexplicitly. A misplaced link and a leaked link require different handling. - Never treat a channel id from a client as authorization.
Next: buyer access sessions and the private and partner sales tutorial.