Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ jobs:
exit 1
fi
count=$(wc -l < /tmp/contents.txt)
if [ "$count" -ne 11 ]; then
echo "::error::expected 11 files in the gem, found $count"
if [ "$count" -ne 12 ]; then
echo "::error::expected 12 files in the gem, found $count"
exit 1
fi

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

## 0.5.0 — 2026-08-21

- Added `performance_groups`, the trusted server resource for fixed two-to-eight
performance runs. It creates and activates groups, mints one-time browser
access, retrieves authoritative group holds, and confirms bookings with
stable action and order references. Browser-only group routes remain outside
this secret-key SDK.

- Added `templates.instantiate_template` and the ticket-release lifecycle on
`events` (`list_ticket_releases`, `update_ticket_releases`, and
`close_ticket_release`). Template instantiation sends `{}` when no overrides
Expand Down
5 changes: 4 additions & 1 deletion lib/seatlayer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require_relative "seatlayer/resources"
require_relative "seatlayer/inventory"
require_relative "seatlayer/channels"
require_relative "seatlayer/performance_groups"
require_relative "seatlayer/webhook"

# Official Ruby server SDK for the SeatLayer reserved-seating API.
Expand All @@ -19,7 +20,8 @@
module SeatLayer
# The SeatLayer client.
class Client
attr_reader :charts, :events, :inventory, :channels, :sessions, :webhooks, :workspaces, :templates
attr_reader :charts, :events, :inventory, :channels, :performance_groups, :sessions,
:webhooks, :workspaces, :templates

def initialize(secret_key, base_url: HTTPClient::DEFAULT_BASE_URL,
max_retries: HTTPClient::DEFAULT_MAX_RETRIES,
Expand All @@ -31,6 +33,7 @@ def initialize(secret_key, base_url: HTTPClient::DEFAULT_BASE_URL,
@events = Events.new(@http)
@inventory = Inventory.new(@http)
@channels = Channels.new(@http)
@performance_groups = PerformanceGroups.new(@http)
@sessions = Sessions.new(@http)
@templates = Templates.new(@http)
@webhooks = Webhooks.new(@http)
Expand Down
105 changes: 105 additions & 0 deletions lib/seatlayer/performance_groups.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# frozen_string_literal: true

module SeatLayer
# Fixed multi-performance runs. This is a secret-key surface: mint the
# browser token here, then give that token (never this client) to the
# PerformanceGroupPicker in the browser SDK.
class PerformanceGroups < Resource
# One page of fixed runs.
def list(workspace_id: nil, external_ref: nil, state: nil, limit: nil, cursor: nil)
@client.get("/v1/performance-groups", compact({ "workspaceId" => workspace_id,
"externalRef" => external_ref,
"state" => state, "limit" => limit,
"cursor" => cursor }))
end

# Create a draft from two to eight compatible assigned-seat events. The
# server validates compatibility and exactly replays a request with the
# same idempotency key.
def create(name:, event_keys:, external_ref: UNSET, idempotency_key: nil)
body = { "name" => name, "eventKeys" => event_keys }
body.merge!(supplied({ "externalRef" => external_ref }))
@client.post("/v1/performance-groups", body, idempotency_key: idempotency_key,
retry_policy: :header_replay)
end

def retrieve(performance_group_key)
@client.get(path(performance_group_key))
end

# Only a draft can be deleted. Activated runs retain their audit identity.
def delete(performance_group_key)
@client.delete(path(performance_group_key))
end

# Starts lifecycle coordination. When its response contains a non-terminal
# lifecycle operation, poll +retrieve_lifecycle+ until it completes.
def activate(performance_group_key, expected_revision:)
@client.post(path(performance_group_key, "/activate"),
{ "expectedRevision" => expected_revision })
end

# Stops new group sales. Poll +retrieve_lifecycle+ while the close remains pending.
def close(performance_group_key, expected_revision:)
@client.post(path(performance_group_key, "/close"),
{ "expectedRevision" => expected_revision })
end

def retrieve_lifecycle(performance_group_key, operation_id)
@client.get(path(performance_group_key, "/lifecycle/#{encode(operation_id)}"))
end

# Reveals a one-time, origin-bound browser bearer. It intentionally remains
# single-attempt: a retry could create a token whose only reveal is lost.
def create_buyer_access_session(performance_group_key, allowed_origin:, include_public:,
channel_ids_by_event: nil, expires_in_seconds: nil,
max_quantity: UNSET, buyer_ref: UNSET, partner_ref: UNSET)
body = compact({ "allowedOrigin" => allowed_origin, "includePublic" => include_public,
"channelIdsByEvent" => channel_ids_by_event,
"expiresInSeconds" => expires_in_seconds })
body.merge!(supplied({ "maxQuantity" => max_quantity, "buyerRef" => buyer_ref,
"partnerRef" => partner_ref }))
@client.post(path(performance_group_key, "/buyer-access-sessions"), body)
end

# Token records only: the bearer value is never returned again.
def list_buyer_access_sessions(performance_group_key, limit: nil)
@client.get(path(performance_group_key, "/buyer-access-sessions"), { "limit" => limit })
end

def revoke_buyer_access_session(performance_group_key, session_id)
@client.delete(path(performance_group_key, "/buyer-access-sessions/#{encode(session_id)}"))
end

# Read the trusted server projection before charging; do not price from
# client input or the picker state.
def retrieve_hold(performance_group_key, operation_id)
@client.get(path(performance_group_key, "/holds/#{encode(operation_id)}"))
end

# Confirm external payment for a committed hold. Keep both identifiers
# stable, and poll +retrieve_booking+ if the response is +book_pending+.
def book_hold(performance_group_key, operation_id, book_action_id:, booking_ref:)
@client.post(path(performance_group_key, "/holds/#{encode(operation_id)}/book"),
{ "bookActionId" => book_action_id,
"bookingRef" => normalise_booking_ref(booking_ref) })
end

def retrieve_booking(performance_group_key, action_id)
@client.get(path(performance_group_key, "/bookings/#{encode(action_id)}"))
end

private

def path(performance_group_key, suffix = "")
"/v1/performance-groups/#{encode(performance_group_key)}#{suffix}"
end

def normalise_booking_ref(booking_ref)
value = booking_ref&.strip
return value unless value.nil? || value.empty?

raise ArgumentError, "booking_ref is required and must be a non-empty stable reference"
end
end
end
2 changes: 1 addition & 1 deletion lib/seatlayer/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module SeatLayer
VERSION = "0.4.0"
VERSION = "0.5.0"
end
65 changes: 65 additions & 0 deletions spec/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,71 @@
body = JSON.parse(transport.calls[0].body)
expect(body.keys).to eq(["chartId"])
end

it "maps the complete trusted performance-groups workflow" do
client, transport = build_client([
{ status: 200, body: '{"performanceGroups":[]}' },
{ status: 201, body: '{"performanceGroup":{"key":"pg_1"}}' },
{ status: 200, body: '{"performanceGroup":{"key":"pg_1"}}' },
{ status: 204 },
{ status: 202,
body: '{"lifecycleOperation":{"operationId":"op_a"}}' },
{ status: 202,
body: '{"lifecycleOperation":{"operationId":"op_c"}}' },
{ status: 200,
body: '{"lifecycleOperation":{"operationId":"op_a"}}' },
{ status: 201, body: '{"buyerAccessSession":{"token":"one_time"}}' },
{ status: 200, body: '{"buyerAccessSessions":[]}' },
{ status: 200, body: '{"buyerAccessSession":{"id":"bas_1"}}' },
{ status: 200, body: '{"hold":{"operationId":"hold_1"}}' },
{ status: 202, body: '{"booking":{"actionId":"book_1"}}' },
{ status: 200, body: '{"booking":{"actionId":"book_1"}}' }
])
groups = client.performance_groups

groups.list(workspace_id: "ws_1", external_ref: "bundle-42", state: "active", limit: 5,
cursor: "next_1")
groups.create(name: "Three-night run", event_keys: %w[ev_1 ev_2], external_ref: "bundle-42")
groups.retrieve("pg/a")
groups.delete("pg/a")
groups.activate("pg/a", expected_revision: 3)
groups.close("pg/a", expected_revision: 4)
groups.retrieve_lifecycle("pg/a", "op/activate")
groups.create_buyer_access_session(
"pg/a", allowed_origin: "https://tickets.example", include_public: false,
channel_ids_by_event: { "ev_1" => ["ch_1"] }, expires_in_seconds: 600,
max_quantity: 4, buyer_ref: "buyer_1", partner_ref: nil
)
groups.list_buyer_access_sessions("pg/a", limit: 10)
groups.revoke_buyer_access_session("pg/a", "bas/1")
groups.retrieve_hold("pg/a", "hold/1")
groups.book_hold("pg/a", "hold/1", book_action_id: "book/1", booking_ref: "order-9")
groups.retrieve_booking("pg/a", "book/1")

expect(transport.calls[0].url).to eq(
"https://api.seatlayer.io/v1/performance-groups?workspaceId=ws_1&externalRef=bundle-42&state=active&limit=5&cursor=next_1"
)
expect(transport.calls[1].headers["Idempotency-Key"])
.to match(/\A[A-Za-z0-9._:-]{1,128}\z/)
expect(transport.calls[2].url).to end_with("/v1/performance-groups/pg%2Fa")
expect(transport.calls[3].http_method).to eq("DELETE")
expect(transport.calls[4].body).to eq('{"expectedRevision":3}')
expect(transport.calls[5].body).to eq('{"expectedRevision":4}')
expect(transport.calls[6].url).to end_with("/lifecycle/op%2Factivate")
expect(transport.calls[7].headers).not_to have_key("Idempotency-Key")
expect(JSON.parse(transport.calls[7].body)).to include(
"includePublic" => false, "maxQuantity" => 4, "partnerRef" => nil
)
expect(transport.calls[8].url).to end_with("/buyer-access-sessions?limit=10")
expect(transport.calls[9].url).to end_with("/buyer-access-sessions/bas%2F1")
expect(transport.calls[10].url).to end_with("/holds/hold%2F1")
expect(transport.calls[11].headers).not_to have_key("Idempotency-Key")
expect(transport.calls[11].url).to end_with("/holds/hold%2F1/book")
expect(JSON.parse(transport.calls[11].body)).to eq(
"bookActionId" => "book/1", "bookingRef" => "order-9"
)
expect(transport.calls[12].url).to end_with("/bookings/book%2F1")
end
end

describe "errors" do
Expand Down