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
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 the public template-instantiation resource and ticket-release list, replace, and close
operations. Template instantiation uses exact header replay; ticket-release mutations remain
deliberately single-attempt.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ build-backend = "hatchling.build"

[project]
name = "seatlayer"
version = "0.4.0"
version = "0.5.0"
description = "Official Python server SDK for the SeatLayer reserved-seating API."
readme = "README.md"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/seatlayer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
)
from .webhooks import WebhookVerificationError, verify_webhook

__version__ = "0.4.0"
__version__ = "0.5.0"

__all__ = [
"AccessLink",
Expand Down
2 changes: 2 additions & 0 deletions src/seatlayer/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
Charts,
Events,
Inventory,
PerformanceGroups,
Sessions,
Templates,
Webhooks,
Expand Down Expand Up @@ -45,6 +46,7 @@ def __init__(
self.templates = Templates(self._http)
self.events = Events(self._http)
self.inventory = Inventory(self._http)
self.performance_groups = PerformanceGroups(self._http)
self.channels = Channels(self._http)
self.sessions = Sessions(self._http)
self.webhooks = Webhooks(self._http)
Expand Down
141 changes: 141 additions & 0 deletions src/seatlayer/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -1152,3 +1152,144 @@ def update(self, workspace_id: str, **fields: Any) -> Any:
409 ``default_workspace_required``. Promote another one first.
"""
return self._http.patch(f"/v1/workspaces/{quote(workspace_id)}", body=fields)


class PerformanceGroups:
"""Fixed multi-performance runs, kept entirely on your trusted server.

Mint the one-time browser bearer here, then give it to
``PerformanceGroupPicker`` in the browser SDK. Lifecycle and booking calls
remain secret-key operations because they coordinate inventory across every
performance in the run.
"""

def __init__(self, http: HttpClient) -> None:
self._http = http

@staticmethod
def _path(performance_group_key: str, suffix: str = "") -> str:
return f"/v1/performance-groups/{quote(performance_group_key)}{suffix}"

def list(
self,
workspace_id: str | None = None,
external_ref: str | None = None,
state: Literal["draft", "active", "closing", "closed", "archived"] | None = None,
limit: int | None = None,
cursor: str | None = None,
) -> Any:
return self._http.get("/v1/performance-groups", query={
"workspaceId": workspace_id,
"externalRef": external_ref,
"state": state,
"limit": limit,
"cursor": cursor,
})

def create(
self,
name: str,
event_keys: Sequence[str],
external_ref: str | None | _Unset = _UNSET,
idempotency_key: str | None = None,
) -> Any:
"""Create a draft run with exact idempotency replay."""
body: dict[str, Any] = {"name": name, "eventKeys": list(event_keys)}
if external_ref is not _UNSET:
body["externalRef"] = external_ref
return self._http.post_with_header_replay(
"/v1/performance-groups", body=body, idempotency_key=idempotency_key
)

def retrieve(self, performance_group_key: str) -> Any:
return self._http.get(self._path(performance_group_key))

def delete(self, performance_group_key: str) -> None:
"""Delete a draft only. Activated runs remain available for audit."""
self._http.delete(self._path(performance_group_key))

def activate(self, performance_group_key: str, expected_revision: int) -> Any:
"""Start activation; poll ``retrieve_lifecycle`` if it is not terminal."""
return self._http.post(
self._path(performance_group_key, "/activate"),
body={"expectedRevision": expected_revision},
)

def close(self, performance_group_key: str, expected_revision: int) -> Any:
"""Stop new sales; poll the returned lifecycle operation until terminal."""
return self._http.post(
self._path(performance_group_key, "/close"),
body={"expectedRevision": expected_revision},
)

def retrieve_lifecycle(self, performance_group_key: str, operation_id: str) -> Any:
return self._http.get(
self._path(performance_group_key, f"/lifecycle/{quote(operation_id)}")
)

def create_buyer_access_session(
self,
performance_group_key: str,
allowed_origin: str,
include_public: bool,
channel_ids_by_event: dict[str, Sequence[str]] | None = None,
expires_in_seconds: int | None = None,
max_quantity: int | None = None,
buyer_ref: str | None = None,
partner_ref: str | None = None,
) -> Any:
"""Reveal one browser token. Never retry this call automatically."""
body: dict[str, Any] = {
"allowedOrigin": allowed_origin,
"includePublic": include_public,
}
for key, value in (
("channelIdsByEvent", channel_ids_by_event),
("expiresInSeconds", expires_in_seconds),
("maxQuantity", max_quantity),
("buyerRef", buyer_ref),
("partnerRef", partner_ref),
):
if value is not None:
body[key] = value
return self._http.post(
self._path(performance_group_key, "/buyer-access-sessions"), body=body
)

def list_buyer_access_sessions(
self, performance_group_key: str, limit: int | None = None
) -> Any:
return self._http.get(
self._path(performance_group_key, "/buyer-access-sessions"),
query={"limit": limit},
)

def revoke_buyer_access_session(
self, performance_group_key: str, session_id: str
) -> Any:
return self._http.delete(
self._path(performance_group_key, f"/buyer-access-sessions/{quote(session_id)}")
)

def retrieve_hold(self, performance_group_key: str, operation_id: str) -> Any:
return self._http.get(
self._path(performance_group_key, f"/holds/{quote(operation_id)}")
)

def book_hold(
self,
performance_group_key: str,
operation_id: str,
book_action_id: str,
booking_ref: str,
) -> Any:
"""Book a committed hold using stable IDs; poll ``retrieve_booking`` if pending."""
return self._http.post(
self._path(performance_group_key, f"/holds/{quote(operation_id)}/book"),
body={"bookActionId": book_action_id, "bookingRef": booking_ref},
)

def retrieve_booking(self, performance_group_key: str, action_id: str) -> Any:
return self._http.get(
self._path(performance_group_key, f"/bookings/{quote(action_id)}")
)
56 changes: 56 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,62 @@ def test_drops_none_query_parameters(self) -> None:
sdk.charts.list(workspace_id="ws_1")
assert calls[0].full_url == "https://api.seatlayer.io/v1/charts?workspaceId=ws_1"

def test_maps_the_full_performance_group_lifecycle(self) -> None:
sdk, calls = make_client([
{"status": 200, "body": {"performanceGroups": [], "nextCursor": None}},
{"status": 201, "body": {"performanceGroup": {}}},
{"status": 200, "body": {"performanceGroup": {}}},
{"status": 204, "body": None},
{"status": 202, "body": {"lifecycleOperation": {"terminal": False}}},
{"status": 200, "body": {"lifecycleOperation": {"terminal": True}}},
{"status": 200, "body": {"lifecycleOperation": {}}},
{"status": 201, "body": {"token": "bsg_secret"}},
{"status": 200, "body": {"sessions": []}},
{"status": 200, "body": {"ok": True}},
{"status": 200, "body": {"hold": {}}},
{"status": 202, "body": {"booking": {"state": "book_pending"}}},
{"status": 200, "body": {"booking": {"state": "booked"}}},
])
group_key = "pg_a/b"

sdk.performance_groups.list(workspace_id="ws_1", state="draft")
sdk.performance_groups.create(
"Weekend run", ["ev_1", "ev_2"], idempotency_key="weekend-run-1"
)
sdk.performance_groups.retrieve(group_key)
assert sdk.performance_groups.delete(group_key) is None
sdk.performance_groups.activate(group_key, 1)
sdk.performance_groups.close(group_key, 2)
sdk.performance_groups.retrieve_lifecycle(group_key, "pga_1")
sdk.performance_groups.create_buyer_access_session(
group_key, "https://tickets.example.test", True
)
sdk.performance_groups.list_buyer_access_sessions(group_key, limit=25)
sdk.performance_groups.revoke_buyer_access_session(group_key, "pgbs_1")
sdk.performance_groups.retrieve_hold(group_key, "pgh_1")
sdk.performance_groups.book_hold(group_key, "pgh_1", "book_1", "order_1")
sdk.performance_groups.retrieve_booking(group_key, "book_1")

base = "https://api.seatlayer.io/v1/performance-groups/pg_a%2Fb"
assert calls[0].full_url == (
"https://api.seatlayer.io/v1/performance-groups?workspaceId=ws_1&state=draft"
)
assert calls[1].full_url.endswith("/v1/performance-groups")
assert calls[1].get_header("Idempotency-key") == "weekend-run-1"
assert calls[2].full_url == base
assert calls[3].method == "DELETE"
assert calls[4].full_url == f"{base}/activate"
assert calls[5].full_url == f"{base}/close"
assert calls[6].full_url == f"{base}/lifecycle/pga_1"
assert calls[7].full_url == f"{base}/buyer-access-sessions"
assert calls[7].get_header("Idempotency-key") is None
assert calls[8].full_url == f"{base}/buyer-access-sessions?limit=25"
assert calls[9].full_url == f"{base}/buyer-access-sessions/pgbs_1"
assert calls[10].full_url == f"{base}/holds/pgh_1"
assert calls[11].full_url == f"{base}/holds/pgh_1/book"
assert calls[11].get_header("Idempotency-key") is None
assert calls[12].full_url == f"{base}/bookings/book_1"


class TestErrors:
@pytest.mark.parametrize(
Expand Down
Loading