---
title: "Buyer Access Sessions API"
description: "Mint, list, and revoke short-lived event-and-origin-bound bse_ tokens for private and scoped buyer audiences."
---

A buyer access session is the credential that lets one browser see and select one
event audience's inventory. Use this server API when access depends on a login,
invitation, presale, package purchase, partner handshake, or named sales channel.
Your backend decides who deserves that scope and then asks SeatLayer to mint the
session.

Read [Sales channels and allocations](/platform/sales-channels/) for the access
model and the [organizer private-sales guide](/integrations/private-and-partner-sales/)
for the complete browser-to-booking flow. This page owns the exact session
contract.

For ordinary Public sale on a Platform/SDK event, pass `publicKey` to the Buyer
SDK instead. SeatLayer validates the account, event mode, and exact registered
browser origin, then returns a Public-only in-memory bearer together with the
chart and compact inventory status. That direct bootstrap cannot expose private
inventory. If `buyerAccessTokenProvider` or `buyerAccessToken` is also present,
the explicit scoped credential always takes precedence over `publicKey`.

**Your server authorizes; SeatLayer mints; the widget consumes.** These endpoints
hand out authority to spend an allocation, so they accept a secret key and
nothing else. A `bse_` token can never book: booking stays with your backend.

The same session also authorizes SeatLayer-hosted view-from-seat image bytes.
The current SDK fetches those Event-scoped assets with the bearer in the
`Authorization` header and renders an in-memory blob URL. Do not append the
token to an image URL or proxy it through query parameters.

## Private audience flow

Use this flow only after you have chosen a private audience. It is not part of
the ordinary Public-sale load path.

1. The buyer opens your page and signs in, enters a presale code, follows an
   invitation, or otherwise proves eligibility to **your** application.
2. Your backend decides which private channel(s), if any, that buyer may see;
   it also decides whether the same buyer may see Public sale.
3. Your backend calls this secret-key endpoint with that exact scope, the
   browser's exact origin, a short expiry, and a retry-safe `clientRequestId`.
4. Return only `{ token, expiresAt }` to the browser. Keep it in memory and
   supply it through `buyerAccessTokenProvider` so the SDK can refresh it.
5. The browser creates holds with that scoped access. Your backend inspects the
   hold and books it; neither the token nor the browser can book directly.

For an ordinary Platform/SDK Public sale, stop before step 1: pass `publicKey`
to the SDK and let SeatLayer perform the public, exact-origin bootstrap.

## Mint a session

<ApiEndpoint method="POST" path="/v1/events/:key/buyer-access-sessions" auth="Secret key" />

```bash
curl -s -X POST "https://api.seatlayer.io/v1/events/ev_9f3a/buyer-access-sessions" \
  -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \
  -H "content-type: application/json" \
  -d '{
    "channelIds": ["chn_9f1c"],
    "includePublic": false,
    "allowedOrigin": "https://booking.travel-agency.example",
    "expiresInSeconds": 1800,
    "maxQuantity": 4,
    "buyerRef": "buyer_8372",
    "partnerRef": "travel-agency-a",
    "clientRequestId": "agency-login-01J8F2A7MRQ4"
  }'
```

| Field | Type | Required | Default | Notes |
|---|---|---:|---|---|
| `allowedOrigin` | `string` | Yes | — | One canonical HTTPS origin, checked on every request |
| `includePublic` | `boolean` | **Yes, explicitly** | none | No default. See below |
| `channelIds` | `string[]` | No | `[]` | Up to 20. Each must belong to this event and be active |
| `expiresInSeconds` | `integer` | No | `1800` | 60 to 43,200 (12 hours) |
| `maxQuantity` | `integer \| null` | No | `null` | 1 to 100. Guest-weighted, summed across **all** this buyer's live holds |
| `buyerRef` | `string` | No | `null` | Your opaque, pseudonymous buyer reference. Max 120 characters |
| `partnerRef` | `string` | No | `null` | Your opaque partner reference. Its presence marks the sale `partner` rather than `promoter` |
| `clientRequestId` | `string` | No | `null` | Makes a retry safe. See below |

Mode comes from the event, not the body. A key whose mode differs from the
event's gets `403 buyer_access_mode_mismatch`.

### `includePublic` has no default

You must send it. `includePublic: true` can expose public inventory to a partner;
`includePublic: false` can leave a VIP with an empty map. SeatLayer refuses to
guess. Omitting it returns `422
include_public_required`.

An empty `channelIds` **and** `includePublic: false` is an empty scope and
returns `422 invalid_channel_scope`. A session that can see nothing is a bug,
not a configuration.

### Response

```json
{
  "sessionId": "bas_2f4c",
  "token": "bse_ZXZ0X2FiYw_9d3f",
  "expiresAt": 1764601800000,
  "eventKey": "ev_9f3a",
  "includePublic": false,
  "maxQuantity": 4
}
```

Keep `sessionId` for audit and revocation. Return only `token` and `expiresAt`
to the browser.

<Aside type="danger" title="The token exists only in this response">
  SeatLayer stores a SHA-256 hash of it and nothing else. The token cannot be
  read back out of the database. Hand it to the browser, keep it in
  memory, keep it out of logs and telemetry, and let it expire.
</Aside>

The response deliberately carries no channel names, no allocation labels, no
partner details, and no internal notes.

### Retries are safe, and never replay a token

Send a `clientRequestId`. On a repeat for the same issuer and id, SeatLayer
**revokes any earlier session for that pair and issues a fresh one**. The response
contains a new `sessionId` and bearer rather than a stored plaintext token. The
earlier token stops working immediately.

This is why buyer access sessions do not use the platform's generic idempotency
replay: that mechanism persists complete response JSON, and this response
contains a bearer.

## List sessions

<ApiEndpoint method="GET" path="/v1/events/:key/buyer-access-sessions" auth="Secret key" />

Newest first. `limit` defaults to 50, capped at 200.

```json
{
  "sessions": [
    {
      "sessionId": "bas_2f4c",
      "channelIds": ["chn_9f1c"],
      "includePublic": false,
      "allowedOrigin": "https://booking.travel-agency.example",
      "mode": "live",
      "expiresAt": 1764601800000,
      "maxQuantity": 4,
      "buyerRef": null,
      "partnerRef": null,
      "accessSource": "promoter",
      "state": "active",
      "createdAt": 1764600000000,
      "revokedAt": null,
      "accessLinkId": null
    }
  ]
}
```

`state` is `active` or `revoked`. Expiry is derived from `expiresAt`, never
stored as a state, so an expired session still lists as `active` with a past
timestamp. `accessSource` is `promoter`, `partner`, or `hosted_link`.
`accessLinkId` is set when the session came from a
[hosted access link](/server-api/channels) rather than a direct mint.

The token is never here, in any form.

## Revoke a session

<ApiEndpoint method="DELETE" path="/v1/events/:key/buyer-access-sessions/:sessionId" auth="Secret key" />

```json
{ "ok": true, "sessionId": "bas_2f4c", "grantVersion": 7 }
```

Revoking twice is not an error.

### Revocation is immediate and ordered

By the time this call returns, **no further hold can use that session**. The
event's own inventory authority marks the grant revoked and bumps a monotonic
grant version before acknowledging, and any live map still on screen is
disconnected in the same instant with WebSocket close code `4401`.

A revocation racing a hold therefore has one order, not a race: if the hold
lands first it exists under the ordinary hold policy; once the revoke is
acknowledged, nothing later can use that grant.

Revocation stops new availability sessions, holds, replacement holds, resumes,
and extensions. It does **not**:

- change historical booking attribution;
- prevent the buyer releasing a hold they already have;
- stop your trusted backend booking an already-valid hold until its normal
  expiry.

## What a session cannot do

| Situation | Result |
|---|---|
| Valid `publicKey` bootstrap on a Platform/SDK event | Public-only session plus chart and compact inventory status |
| No `Authorization` or public bootstrap on a Platform/SDK event | `404 not_found`; an event key is routing, not audience authority |
| No `Authorization` on a Managed public/unlisted event | Existing anonymous Public sale behavior is unchanged |
| Expired, revoked, wrong origin, or wrong event | A specific typed error; no fallback to public |
| Browser claims a `channelId` it was not granted | Ignored. The server derives scope from the credential |
| Buyer opens a second tab | `maxQuantity` is summed across all live holds, so the allowance does not double |
| Session expires while a hold is active | The buyer can still **release** it; your backend can still book it |

The API fails closed rather than degrading to public. This prevents a private
buyer from receiving a wider scope.

## Errors

| Status | Code | What to do |
|---:|---|---|
| 401 | `buyer_access_invalid` | Get a new session; do not retry the same bearer |
| 401 | `buyer_access_expired` | Run your refresh flow |
| 403 | `buyer_access_origin_mismatch` | Stop; check the configured origin |
| 403 | `buyer_access_event_mismatch` | Stop; do not reuse a token across events |
| 403 | `buyer_access_mode_mismatch` | Match test and live |
| 403 | `channel_access_denied` | Return the buyer to inventory they may see. Do not reveal channel details |
| 404 | `not_found` | Unknown or cross-tenant event, session, or channel |
| 409 | `allocation_exhausted` | This private allocation has no inventory left. Do not say "sold out"; the event may still have public inventory |
| 422 | `invalid_channel_scope` | Empty scope, more than 20 channels, or a paused/archived channel |
| 422 | `include_public_required` | Send `includePublic` explicitly |
| 422 | `invalid_allowed_origin` | One canonical HTTPS origin |
| 422 | `invalid_expiry` | 60 to 43,200 seconds |
| 422 | `invalid_max_quantity` | 1 to 100 |
| 422 | `invalid_reference` | A reference exceeded 120 characters |

## Server SDK

```ts title="server/access.ts"
import SeatLayer from "@seatlayer/server";

const seatlayer = new SeatLayer({ secretKey: process.env.SEATLAYER_SECRET_KEY! });

export async function grantAgencyAccess(eventKey: string, buyerId: string) {
  const session = await seatlayer.channels.createBuyerAccessSession(eventKey, {
    channelIds: ["chn_9f1c"],
    includePublic: false,
    allowedOrigin: "https://booking.travel-agency.example",
    expiresInSeconds: 1800,
    maxQuantity: 4,
    buyerRef: buyerId,
    clientRequestId: `agency-login-${buyerId}`,
  });

  // Persist sessionId for audit and revocation. Return only these two.
  return { token: session.token, expiresAt: session.expiresAt };
}
```

```python title="server/access.py"
session = seatlayer.create_buyer_access_session(
    event_key,
    channel_ids=["chn_9f1c"],
    include_public=False,
    allowed_origin="https://booking.travel-agency.example",
    expires_in_seconds=1800,
    max_quantity=4,
    buyer_ref=buyer_id,
    client_request_id=f"agency-login-{buyer_id}",
)
```

## Checklist

- [ ] Authenticate the buyer yourself before asking SeatLayer to mint. A session is a decision, not a lookup.
- [ ] For ordinary Platform Public sale, use the SDK's direct `publicKey` bootstrap instead of this server endpoint.
- [ ] Mint here only when buyer identity or audience scope requires login, presale, partner, or channel access.
- [ ] Send `includePublic` explicitly, every time.
- [ ] Keep the token in memory in the browser; never in storage, a URL, or a log.
- [ ] Send `clientRequestId` so a retry rotates instead of replaying.
- [ ] Store `sessionId` so you can revoke without waiting for expiry.
- [ ] Handle expired and revoked as distinct outcomes, not as a network failure.
- [ ] Keep test and live scopes separate.

Next: [sales channels API](/server-api/channels) and the
[private and partner sales tutorial](/integrations/private-and-partner-sales).

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| Public Platform picker shows "The seat map didn't load"; bootstrap is `404 not_found` | Missing/wrong-mode `publicKey`, an unregistered exact origin, or a non-Public event lane. Refusals are deliberately indistinguishable | Pass the event account's matching publishable key and register the page's exact Embed domain |
| Scoped private picker shows "The seat map didn't load"; `GET /pub/events/:key/chart` is `404 not_found` | No `Authorization: Bearer bse_…` on the request. A missing bearer is deliberately indistinguishable from an unknown event | Pass `buyerAccessTokenProvider`; after authenticating the buyer, have your server ask SeatLayer for the scoped session |
| `403 buyer_access_origin_mismatch` | `allowedOrigin` differs from the page's origin (scheme, host, or port) | Mint with the exact origin the checkout page is served from; one session per origin |
| `401 buyer_access_expired` after the map rendered | Token lapsed and the provider did not renew | Return a provider function (not a one-shot `buyerAccessToken`) so the SDK can re-mint |
| `422 include_public_required` | `includePublic` omitted from an explicit private-audience mint | Send it explicitly. Use `true` only when that scoped audience should also see Public inventory; ordinary Public sale uses direct `publicKey` bootstrap instead |
| `422 invalid_channel_scope` | `channelIds: []` with `includePublic: false` | A session must see inventory. Add channels or set `includePublic: true` |