Channels are allocation labels on an event’s inventory. Read 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
/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",
"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
/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.
Rename, or change the declared access method
/v1/events/:key/channels/:channelIdSecret key, dashboard session, or event:channels:manage manage tokenSend 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
/v1/events/:key/channels/assignmentsSecret key, dashboard session, or event:channels:manage manage tokenThis is the operation worth reading carefully. It 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. 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:
{
"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
{
"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 — 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 |
{
"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:
{
"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 — pausing never strands 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 when you cannot authenticate each buyer yourself — a sponsor’s guest list, a press allocation — and a buyer access session when you can.
/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, no 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 — 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 |
| 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
endActiveSessionsdeliberately — misplaced and leaked are different. - Never treat a channel id from a client as authorization.
Next: buyer access sessions and the private and partner sales tutorial.