---
title: "Buyer access sessions"
description: "Mint, list, and revoke the short-lived origin-bound bse_ token that lets one buyer select a private sales channel's inventory."
---

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

# Buyer access sessions

A buyer access session is the credential that lets one buyer see and select one
audience's inventory. Your backend decides who deserves it — a login, an
invitation, a package purchase, a partner handshake — and then asks SeatLayer
for a session.

**Your server mints; the widget consumes.** These endpoints hand out the
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.

## Mint a session

**POST /v1/events/:key/buyer-access-sessions** — Authentication: 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. Getting it wrong is expensive in both directions — either a
partner quietly drains your public inventory, or a VIP opens the link and sees
an empty map — so 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.

> **The token exists only in this response**
>
> SeatLayer stores a SHA-256 hash of it and nothing else. Nobody — including us
> — can read it 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.

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** — a new
`sessionId` and a new bearer — rather than replaying a stored plaintext token
back at you. 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

**GET /v1/events/:key/buyer-access-sessions** — Authentication: 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

**DELETE /v1/events/:key/buyer-access-sessions/:sessionId** — Authentication: 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 |
|---|---|
| No `Authorization` header at all | Public sale, exactly as before. Nothing changed for existing buyers |
| Expired, revoked, wrong origin, or wrong event | A specific typed error — **never** a silent downgrade 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 |

Failing closed rather than degrading to public is the point. Silently widening a
private buyer's access is the exact failure this feature exists to prevent.

## 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 not be |
| 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 minting. A session is a decision, not a lookup.
- [ ] 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).

Source: https://docs.seatlayer.io/server-api/buyer-access-sessions/index.mdx
