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, 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.
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 channel1. Create the channel
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.
curl -s "https://api.seatlayer.io/v1/events/ev_9f3a/channels" \
-H "authorization: Bearer $SEATLAYER_SECRET_KEY"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.
3. Check what the agency will actually see
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.
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.
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();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.
// 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.
retrieveHoldreturns 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.
7. Reconcile
The seat.booked webhook names the channel that made the sale:
{
"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: 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:
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
bookingRefand 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
- Channels API · Buyer access sessions
- Hosted access links — when you cannot authenticate each buyer
- Ticketing backend integration