Skip to content

Sales Channels API

Create channels, allocate inventory, set audience prices, preview access, pause or archive channels, and manage hosted links.

Updated View as Markdown

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

GET/v1/events/:key/channelsSecret key, dashboard session, or event:channels:view manage token

Add ?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

POST/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

PATCH/v1/events/:key/channels/:channelIdSecret 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.

{
  "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:

{
  "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

POST/v1/events/:key/channels/assignmentsSecret key, dashboard session, or event:channels:manage manage token

This 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
  • 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.

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

Read the allocation map

GET/v1/events/:key/channels/allocationSecret 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
{
  "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/previewSecret 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
{
  "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

POST/v1/events/:key/channels/:channelId/pauseSecret key, dashboard session, or event:channels:manage manage token
POST/v1/events/:key/channels/:channelId/unpauseSecret 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

POST/v1/events/:key/channels/:channelId/archiveSecret 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.

{
  "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 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.

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.

POST/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

GET/v1/events/:key/channels/:channelId/access-linksSecret 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.

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

DELETE/v1/events/:key/channels/:channelId/access-links/:linkIdSecret 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 and the private and partner sales tutorial.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close