---
title: "Private and partner sales"
description: "Sell one event through a travel agency, presale list, or sponsor allocation end to end — allocate, mint buyer access, hold, and book with attribution."
---

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

# Private and partner sales

This is the end-to-end build for selling one event to more than one audience: a
promoter with a public on-sale, plus a travel agency selling a reserved
allocation of the same seats.

By the end you will have allocated inventory to a
[sales channel](/platform/sales-channels), minted buyer access from your
backend, held seats in the agency's own page, booked them with your own
credential, and received a webhook that says which channel made the sale.

## The shape of it

One event, `ev_9f3a`, with 1,000 seats:

- 800 on Public sale;
- 200 assigned to `Travel Agency A`.

Both audiences load the same event and the same chart. Selling `A-12` through
the agency makes `A-12` booked on the public page too, immediately, because
there is only one inventory.

```mermaid
sequenceDiagram
    participant B as Agency buyer
    participant AB as Agency backend
    participant PB as Promoter backend
    participant S as SeatLayer

    B->>AB: Sign in / validate booking package
    AB->>PB: Request access for this buyer
    PB->>S: Mint buyer access session (event + channel scoped)
    S-->>PB: sessionId + opaque bse_ token
    PB-->>AB: token + expiry
    AB-->>B: Mount SeatLayer with the token
    B->>S: Load availability
    S-->>B: The agency allocation only
    B->>S: Hold selected seats
    S-->>B: holdId
    B->>AB: Continue checkout
    AB->>PB: Confirm order + holdId
    PB->>S: Inspect hold, then book
    S-->>PB: Booked
    S-->>PB: seat.booked webhook, attributed to the channel
```

> **Who books, in this profile**
>
> **The promoter's backend books.** The agency never holds a SeatLayer
> credential — it asks the promoter for buyer access, and it asks the promoter to
> book. Direct partner booking belongs to a future partner-credential profile;
> this is the profile to build today.

## 1. Create the channel

```bash
curl -s -X POST "https://api.seatlayer.io/v1/events/ev_9f3a/channels" \
  -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \
  -H "content-type: application/json" \
  -d '{"name": "Travel Agency A", "externalRef": "travel-agency-a", "accessIntent": "server"}'
```

Set `externalRef` now. It is the key you will reconcile on later, and it survives
a rename.

## 2. Allocate the inventory

Read the current `assignmentVersion`, then apply against it.

```bash
curl -s "https://api.seatlayer.io/v1/events/ev_9f3a/channels" \
  -H "authorization: Bearer $SEATLAYER_SECRET_KEY"
```

```bash
curl -s -X POST "https://api.seatlayer.io/v1/events/ev_9f3a/channels/assignments" \
  -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \
  -H "content-type: application/json" \
  -d '{
    "targetChannelId": "chn_9f1c",
    "labels": ["A-1", "A-2", "A-3"],
    "assignmentVersion": 0
  }'
```

Read the response's buckets rather than assuming it did what you asked. Seats in
someone's checkout and seats already sold are never moved, and they are reported
separately from labels that are not in the event at all. A stale
`assignmentVersion` moves nothing and returns `409`.

Full semantics: [apply an allocation](/server-api/channels).

## 3. Check what the agency will actually see

```bash
curl -s "https://api.seatlayer.io/v1/events/ev_9f3a/channels/preview?channelIds=chn_9f1c&includePublic=0" \
  -H "authorization: Bearer $SEATLAYER_SECRET_KEY"
```

This runs the same projection a real buyer session gets, so it is a genuine
check and not an approximation. Do it before the partner goes live; it is much
cheaper than discovering the allocation was wrong from a buyer.

## 4. Mint buyer access, from your backend

The agency's backend authenticates its own customer, then asks yours. Yours
decides, then mints.

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

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

export async function grantAgencyAccess(agencyBuyerId: string) {
  // Your rule, not SeatLayer's: is this buyer entitled to the agency block?
  const entitled = await agencyContract.isActiveBuyer(agencyBuyerId);
  if (!entitled) throw new Error("not entitled");

  const session = await seatlayer.channels.createBuyerAccessSession("ev_9f3a", {
    channelIds: ["chn_9f1c"],
    includePublic: false,
    allowedOrigin: "https://booking.travel-agency.example",
    expiresInSeconds: 1800,
    maxQuantity: 4,
    buyerRef: agencyBuyerId,
    partnerRef: "travel-agency-a",
    clientRequestId: `agency-access-${agencyBuyerId}`,
  });

  await audit.record({ sessionId: session.sessionId, agencyBuyerId });

  // Only these two cross the wire to the browser.
  return { token: session.token, expiresAt: session.expiresAt };
}
```

Four decisions worth making deliberately:

- **`includePublic: false`** — the agency sells its own block, not your public
  inventory. There is no default; you must say.
- **`allowedOrigin`** — the agency's exact production origin, checked on every
  request.
- **`maxQuantity: 4`** — guest-weighted across *all* that buyer's live holds, so
  a second tab does not double the allowance.
- **`partnerRef`** — flows to the report and the booking webhook as the
  reconciliation key. It is opaque; it is not a credential.

`clientRequestId` makes the call safe to retry: a repeat revokes the earlier
session and issues a fresh token rather than replaying a stored bearer.

## 5. Mount the map with the token

In the agency's own page, give the SDK a **provider**, not a token. The provider
is called for the initial token and again on expiry, so a buyer who lingers is
refreshed instead of dropped.

```js title="agency-frontend/picker.js"
const chart = new seatlayer.SeatingChart({
  container: "#seat-map",
  event: "ev_9f3a",
  buyerAccessTokenProvider: async ({ reason }) => {
    const res = await fetch("/api/seatlayer-access", {
      method: "POST",
      credentials: "include",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ reason }),
    });
    if (!res.ok) throw new Error("Could not establish access");
    return res.json(); // { token, expiresAt }
  },
});

await chart.render();
```

> **Do not pass channels from the browser**
>
> There is no `channels: [...]` option, and there never will be. A channel id in
> browser code is a label the server ignores. Scope comes from the token.
>
> Hold the token in memory only. Not `localStorage`, not a query string, not
> telemetry, not exception text. Ask for a new one after a reload.

The buyer now sees the agency's 200 seats. Everything else reads as one flat
unavailable state — not "booked", not "held", nothing that would let them infer
activity inside your public inventory.

## 6. Hold, then book from your backend

The browser holds. It never books.

```ts title="promoter-backend/checkout.ts"
// The agency sends you its order and the holdId the buyer's browser produced.
const hold = await seatlayer.inventory.retrieveHold("ev_9f3a", holdId);

// Authoritative pricing: build the total from this, never from the browser.
const total = hold.items.reduce((sum, i) => sum + i.unitPrice * (i.quantity ?? 1), 0);
if (total !== agencyOrder.total) throw new Error("price mismatch");

await payments.capture(agencyOrder);

await seatlayer.inventory.book("ev_9f3a", {
  holdId,
  bookingRef: agencyOrder.id,
});
```

Two things this gets right:

- **The hold is the authority.** `retrieveHold` returns the server's own items,
  including the channel and access source captured when the seats were taken.
  Comparing that against what the partner claims is how you catch a forged price
  or channel.
- **Booking a hold needs no channel argument.** The hold already carries its
  authorization, so this works even if the buyer's session has since expired. It
  is *direct label booking* — booking without a `holdId` — that must name a
  channel on private inventory. See
  [the behavior change](/server-api/booking).

## 7. Reconcile

The `seat.booked` webhook names the channel that made the sale:

```json
{
  "event": "seat.booked",
  "payload": {
    "eventId": "ev_9f3a",
    "bookingRef": "agency-order-4412",
    "items": [
      {
        "label": "A-12",
        "unitPrice": 75,
        "currency": "USD",
        "channelId": "chn_9f1c",
        "channelExternalRef": "travel-agency-a",
        "accessSource": "partner",
        "partnerRef": "travel-agency-a"
      }
    ]
  }
}
```

Attribution is recorded at booking time and never rewritten, so it stays correct
even after you move or archive the channel. Reconcile on `bookingRef`, your own
order id, the partner's order id, `channelExternalRef`, and the delivery's
`occurrenceId`.

For the running totals, read
[`GET /v1/events/:key/channels/report`](/server-api/reports): what each channel
holds now, beside what it sold.

## 8. Wind the allocation down

When the agency's window closes, archive the channel with a destination:

```bash
curl -s -X POST "https://api.seatlayer.io/v1/events/ev_9f3a/channels/chn_9f1c/archive" \
  -H "authorization: Bearer $SEATLAYER_SECRET_KEY" \
  -H "content-type: application/json" \
  -d '{"destination": "public", "reason": "Agency window closed"}'
```

Unsold seats return to Public sale, the agency's buyer sessions are revoked in
the same operation, and the sales it already made stay attributed to it forever.
If anyone is mid-checkout, archive is refused with a `retryAfterMs` rather than
stranding them.

## Go-live checklist

- [ ] The allocation quantity, public-sale access, booking owner, payment owner, and cancellation policy are agreed in writing.
- [ ] Preview matches the intended inventory.
- [ ] Test and live credentials and scopes are separate.
- [ ] The exact production origin is registered on the session.
- [ ] Buyer eligibility is authenticated **before** minting, by your rule.
- [ ] The token is memory-only, redacted from logs, refreshed on expiry, and revocable.
- [ ] The hold is inspected and priced authoritatively before payment.
- [ ] Booking uses a stable `bookingRef` and is safe to retry.
- [ ] Allocation exhausted, event closed, access expired, seat conflict, and network errors each show distinct buyer guidance.
- [ ] Webhook signature and replay handling are tested.
- [ ] Public and partner flows are both tested against one live-like event.
- [ ] You can pause access and return unused allocation without a chart republish.

## Related

- [Sales channels concept](/platform/sales-channels)
- [Channels API](/server-api/channels) · [Buyer access sessions](/server-api/buyer-access-sessions)
- [Hosted access links](/server-api/channels) — when you cannot authenticate each buyer
- [Ticketing backend integration](/integrations/ticketing-backend)

Source: https://docs.seatlayer.io/integrations/private-and-partner-sales/index.mdx
