seatlayer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6aa9b6c1e8985b0f261f5e0bf7743148a2fcf5dc3c9f9585b482d78e24b492a5
4
+ data.tar.gz: 70b5fb6fd1f3eb7cbed9d80917a7e1ef56d212c1bafb924e97a961878e71e906
5
+ SHA512:
6
+ metadata.gz: 21ab307cdbc1676e6d463c3bb990ebe886f5d38906b988d129d1f5a722c4199ffeb80938b582e3d7b906fe287b9ea5a190dda5043200f27f569fc0c2d2aab7f1
7
+ data.tar.gz: d4d261bb2e9240a49bf373fd9faa63896fac970f9fb7a9f5c7583becce1c26a445f45ff623115463e833587f0a58be1762990864dba1066139c384d62d85005e
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-08-04
4
+
5
+ First public release: RubyGems `seatlayer`.
6
+
7
+ Initial contents of the SeatLayer Ruby server SDK.
8
+
9
+ - `SeatLayer::Client` with secret-key auth, per-attempt timeouts, and an escape hatch.
10
+ - Resources: `charts`, `events`, `inventory`, `sessions`, `webhooks`, `workspaces`.
11
+ - Automatic `Idempotency-Key` on every mutation, reused across retries so a retried
12
+ booking cannot become two bookings.
13
+ - Retries on 429/408/5xx with exponential backoff and full jitter; honours `Retry-After`.
14
+ 4xx is never retried.
15
+ - Typed errors: `AuthError` (with `mode_mismatch?`), `ConflictError` (with `conflicts`
16
+ and `sold_out?`), `RateLimitError`, `ValidationError`, `NotFoundError`,
17
+ `ConnectionError` — all under `SeatLayer::Error`.
18
+ - `SeatLayer::Webhook.verify` — raw-body HMAC-SHA256 via `OpenSSL.secure_compare`.
19
+ - `create_manage_session` requires explicit capabilities; the API's default grants
20
+ `event:cancel`, which unbooks paid seats and authorises gateway refunds.
21
+ - Constructor rejects a `pk_` key by name rather than failing as a 401 later.
22
+ - `list_all` returns a lazy `Enumerator` when no block is given, so
23
+ `.lazy.first(n)` does not walk every page.
24
+ - No runtime dependencies — `net/http`, `json` and `openssl` from the standard library.
25
+
26
+ Requires Ruby 3.0 or newer.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SeatLayer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,305 @@
1
+ # SeatLayer Ruby SDK
2
+
3
+ Official Ruby server SDK for the [SeatLayer](https://seatlayer.io) reserved-seating API.
4
+
5
+ > **Server-side only.** This gem authenticates with your secret key. Never load it anywhere a
6
+ > ticket buyer can reach — browser surfaces get short-lived, origin-bound tokens that you mint here.
7
+
8
+ ## Install
9
+
10
+ ```ruby
11
+ gem "seatlayer"
12
+ ```
13
+
14
+ ```bash
15
+ gem install seatlayer
16
+ ```
17
+
18
+ Requires Ruby 3.0 or newer. **No runtime dependencies** — `net/http`, `json` and `openssl` from the
19
+ standard library.
20
+
21
+ ## Quick start
22
+
23
+ ```ruby
24
+ require "seatlayer"
25
+
26
+ client = SeatLayer::Client.new(ENV.fetch("SEATLAYER_SECRET_KEY"))
27
+
28
+ # 1. Provision a venue for a new organiser from one of your templates.
29
+ chart = client.charts.copy("c_template_arena")["meta"]
30
+ client.charts.publish(chart["id"])
31
+
32
+ # 2. Create an event on it.
33
+ event = client.events.create(chart_id: chart["id"], name: "Spring Gala")["meta"]
34
+
35
+ # 3. Sell four seats over the phone.
36
+ held = client.inventory.hold_best_available(event["key"], qty: 4)
37
+ # … take payment against held["items"], which carry authoritative prices …
38
+ client.inventory.book(event["key"], hold_id: held["holdId"], booking_ref: "order-8842")
39
+ ```
40
+
41
+ ## Test vs live
42
+
43
+ Keys carry their own mode. `sk_test_…` keys can only touch test-mode events and `sk_live_…` only
44
+ live ones; crossing them returns `403 mode_mismatch`, surfaced as `AuthError` with `mode_mismatch?`.
45
+
46
+ ```ruby
47
+ client = SeatLayer::Client.new(ENV.fetch("SEATLAYER_SECRET_KEY"))
48
+ raise "Refusing to boot production against test-mode seating data." if
49
+ ENV["RAILS_ENV"] == "production" && client.mode != "live"
50
+ ```
51
+
52
+ A publishable `pk_` key is rejected at construction with a message naming the mistake, rather than
53
+ failing as a `401` three round-trips later.
54
+
55
+ ## The two selling flows
56
+
57
+ **Buyer picks seats in the browser.** Your frontend holds them; your backend confirms the price and
58
+ books. Never price from what the browser sent you — `retrieve_hold` is authoritative.
59
+
60
+ ```ruby
61
+ hold = client.inventory.retrieve_hold(event_key, hold_id)
62
+ total = hold["items"].sum { |item| item["unitPrice"] }
63
+ # … charge `total` in hold["currency"] …
64
+ client.inventory.book(event_key, hold_id: hold_id, booking_ref: charge.id)
65
+ ```
66
+
67
+ **Your backend picks the seats.** Phone orders, box office, comps.
68
+
69
+ ```ruby
70
+ # Payment already taken — book outright, so nothing is stranded if a second call fails.
71
+ client.inventory.book_best_available(event_key, qty: 2, booking_ref: "phone-1183")
72
+
73
+ # Or name the seats yourself.
74
+ client.inventory.box_office_book(event_key, labels: ["A-1", "A-2"], booking_ref: "comp-14")
75
+ ```
76
+
77
+ ## Listing and pagination
78
+
79
+ `list` returns one page plus a `nextCursor`. `list_all` pages for you and returns a lazy
80
+ `Enumerator` when no block is given — the point of paginating is to *not* hold an unbounded result
81
+ set in memory, so `.lazy.first(n)` stops fetching once it has enough.
82
+
83
+ ```ruby
84
+ # One page, your own paging.
85
+ page = client.events.list(limit: 50)
86
+ page["events"]
87
+ page["nextCursor"] # nil once exhausted
88
+
89
+ # Or let the SDK walk it.
90
+ client.events.list_all do |event|
91
+ sync(event)
92
+ end
93
+
94
+ # Lazily — this fetches one page, not all of them.
95
+ client.charts.list_all.lazy.first(5)
96
+ ```
97
+
98
+ Listing events includes live availability `counts` by default, which costs the server one
99
+ round-trip **per event**. `list_all` turns them off automatically — walking a whole catalogue is
100
+ exactly when you don't want that — and you can control it explicitly:
101
+
102
+ ```ruby
103
+ client.events.list(limit: 50, counts: false)
104
+ ```
105
+
106
+ ## Keeping a hold alive
107
+
108
+ When an order takes longer than the checkout window — an invoice, a phone sale — extend rather than
109
+ release and re-hold. Releasing first hands the seats to whoever is racing for them in between.
110
+
111
+ ```ruby
112
+ begin
113
+ client.inventory.extend_hold(event_key, hold_id, ttl_ms: 10 * 60_000)
114
+ rescue SeatLayer::ConflictError
115
+ # Gone, expired, or at its renewal cap — the buyer has to re-pick.
116
+ end
117
+ ```
118
+
119
+ ## Embedding the control room
120
+
121
+ Your secret key never reaches a browser. Mint a scoped token instead.
122
+
123
+ ```ruby
124
+ session = client.sessions.create_manage_session(
125
+ event_key,
126
+ allowed_origin: "https://box-office.yourplatform.com",
127
+ capabilities: ["event:view", "event:block"],
128
+ expires_in_seconds: 3600
129
+ )
130
+ ```
131
+
132
+ `capabilities` is **required** by this SDK even though the API defaults it. Omit it at the API level
133
+ and you get `event:view`, `event:block`, `event:cancel` and `event:reports` — including
134
+ `event:cancel`, which unbooks paid seats **and authorises refunds against the organiser's connected
135
+ payment gateway**. That is real money, moved by a token you handed to a browser; it should not
136
+ arrive by forgetting an argument. Grant the smallest set the page needs.
137
+
138
+ The full set, all opt-in:
139
+
140
+ | Capability | Grants |
141
+ |---|---|
142
+ | `event:view` | Read the seat map and its live states |
143
+ | `event:block` | Block and unblock seats |
144
+ | `event:cancel` | Unbook paid seats and issue gateway refunds — destructive, moves money |
145
+ | `event:reports` | Read sales and availability reports |
146
+ | `event:channels:view` | Read sales channels and their allocations |
147
+ | `event:channels:manage` | Create, pause and archive channels; rotate access links |
148
+
149
+ The two `event:channels:*` capabilities are **not** in the default — a token minted before sales
150
+ channels existed must not silently acquire channel authority — so ask for them explicitly if the
151
+ page manages channels.
152
+
153
+ ## Webhooks
154
+
155
+ Verify every delivery against the **raw** body. Re-encoding a parsed Hash changes the bytes and
156
+ verification will fail.
157
+
158
+ ```ruby
159
+ # Rails
160
+ class WebhooksController < ApplicationController
161
+ skip_before_action :verify_authenticity_token
162
+
163
+ def seatlayer
164
+ event = SeatLayer::Webhook.verify(
165
+ request.raw_post, # raw body, never params
166
+ request.headers["X-SeatLayer-Signature"],
167
+ ENV.fetch("SEATLAYER_WEBHOOK_SECRET")
168
+ )
169
+
170
+ # The signed body carries `at`, but nothing enforces a freshness window, so a
171
+ # captured delivery stays valid indefinitely. Deduplicate on occurrenceId —
172
+ # this is your replay protection, not an optimisation.
173
+ return head :ok if already_processed?(event["occurrenceId"])
174
+
175
+ Handler.call(event)
176
+ head :ok
177
+ rescue SeatLayer::WebhookVerificationError
178
+ head :bad_request
179
+ end
180
+ end
181
+ ```
182
+
183
+ ## Errors
184
+
185
+ ```ruby
186
+ begin
187
+ client.inventory.hold_best_available(event_key, qty: 6)
188
+ rescue SeatLayer::ConflictError => e
189
+ return show_alternative_dates if e.sold_out? # a business outcome, not a bug
190
+ raise
191
+ rescue SeatLayer::RateLimitError => e
192
+ return retry_after(e.retry_after)
193
+ rescue SeatLayer::AuthError => e
194
+ raise "Test key pointed at a live event, or the reverse." if e.mode_mismatch?
195
+ raise
196
+ end
197
+ ```
198
+
199
+ | Class | Status | Means |
200
+ |---|---|---|
201
+ | `AuthError` | 401, 403 | Bad, revoked, or wrong-mode key |
202
+ | `NotFoundError` | 404 | No such resource *for this organisation* |
203
+ | `ConflictError` | 409 | Inventory moved, or a guard rejected the change |
204
+ | `ValidationError` | 422 | Understood and rejected |
205
+ | `RateLimitError` | 429 | Over budget; carries `retry_after` |
206
+ | `ConnectionError` | — | No answer: DNS, TLS, socket, timeout |
207
+
208
+ All descend from `SeatLayer::Error`, so `rescue SeatLayer::Error` catches everything. Every API
209
+ error carries `status`, `code`, `body` and `request_id` — quote the request id in support requests.
210
+
211
+ ## Reliability
212
+
213
+ **Retries.** 429, 408 and 5xx are retried with exponential backoff and full jitter; `Retry-After`
214
+ wins when the server sends it. 4xx is never retried — it will not start succeeding.
215
+
216
+ **Idempotency.** Every mutating request carries an `Idempotency-Key`, generated if you do not supply
217
+ one, and **reused across retries** so a retried booking cannot become two bookings. Pass your own
218
+ order id for end-to-end deduplication:
219
+
220
+ ```ruby
221
+ client.inventory.book(event_key, hold_id: hold_id, idempotency_key: "order-#{order_id}")
222
+ ```
223
+
224
+ ```ruby
225
+ SeatLayer::Client.new(
226
+ ENV.fetch("SEATLAYER_SECRET_KEY"),
227
+ max_retries: 3, # total attempts
228
+ timeout: 30.0 # seconds, per attempt
229
+ )
230
+ ```
231
+
232
+ ## Escape hatch
233
+
234
+ For surface this SDK does not wrap yet — same auth, retries, idempotency and error mapping:
235
+
236
+ ```ruby
237
+ client.request("POST", "/v1/events/ev_1/some-new-route", body: { "qty" => 2 })
238
+ ```
239
+
240
+ ## API surface
241
+
242
+ | Resource | Methods |
243
+ | --- | --- |
244
+ | `charts` | `list` `list_all` `create` `retrieve` `update` `delete` `copy` `archive` `unarchive` `publish` |
245
+ | `events` | `list` `list_all` `create` `retrieve` `update` `delete` `update_chart` `close` `reopen` `archive` `retrieve_hold_ttl` `update_hold_ttl` `retrieve_report` `retrieve_log` |
246
+ | `inventory` | `hold` `hold_best_available` `book_best_available` `extend_hold` `retrieve_hold` `release` `book` `box_office_book` `unbook` `block` `unblock` `unblock_all` `retrieve_availability` `update_availability` |
247
+ | `sessions` | `create_manage_session` `revoke_manage_session` `create_designer_session` `revoke_designer_session` |
248
+ | `webhooks` | `list` `create` `update` `delete` `list_deliveries` |
249
+ | `workspaces` | `list` `create` `retrieve` `update` |
250
+
251
+ Full reference: [docs.seatlayer.io/server-sdk](https://docs.seatlayer.io/server-sdk/install/)
252
+
253
+ ### Deliberately not in this SDK
254
+
255
+ Some API surface is intentionally unwrapped, not merely pending:
256
+
257
+ - **Hosted-checkout orders and refunds.** Reading or refunding a SeatLayer-hosted-checkout sale is
258
+ not a server-SDK capability. Those records only exist for organisations using hosted checkout; if
259
+ you run your own commerce store you refund in that store, through your own gateway.
260
+ - **Connecting or assigning payment gateways.** Connecting one is a dashboard flow, so shipping only
261
+ the assignment half across seven SDKs would hand you a method that cannot yet succeed.
262
+ - **Realtime seat updates.** Live seat state reaches the *browser* through the widget's own socket.
263
+ There is no server-side subscribe; a secret-key caller gets authoritative state from
264
+ `events.retrieve_report` and `inventory.retrieve_availability`.
265
+
266
+ None of these are reachable through `request` as a supported path either — they are excluded from
267
+ the public manifest, not just from the wrapper.
268
+
269
+ ## Related resources
270
+
271
+ - [Server SDK guide](https://docs.seatlayer.io/server-sdk/install/)
272
+ - [Errors, retries and idempotency](https://docs.seatlayer.io/server-sdk/reliability/)
273
+ - [Webhook verification](https://docs.seatlayer.io/server-sdk/webhooks/)
274
+ - [Server API reference](https://docs.seatlayer.io/server-api/events/)
275
+ - [OpenAPI description](https://docs.seatlayer.io/openapi.json)
276
+ - [SeatLayer GitHub organization](https://github.com/seatlayer)
277
+
278
+ ### Other SeatLayer SDKs
279
+
280
+ | Surface | Package |
281
+ |---|---|
282
+ | Browser (vanilla) | [`@seatlayer/js`](https://github.com/seatlayer/seatlayer-sdk) |
283
+ | React | [`@seatlayer/react`](https://github.com/seatlayer/seatlayer-sdk) |
284
+ | React Native | [`@seatlayer/react-native`](https://github.com/seatlayer/seatlayer-react-native) |
285
+ | iOS | [`seatlayer-ios`](https://github.com/seatlayer/seatlayer-ios) |
286
+ | Android | [`seatlayer-android`](https://github.com/seatlayer/seatlayer-android) |
287
+ | Flutter | [`seatlayer_flutter`](https://github.com/seatlayer/seatlayer-flutter) |
288
+ | Node.js (server) | [`@seatlayer/server`](https://github.com/seatlayer/seatlayer-node) |
289
+ | Python (server) | [`seatlayer`](https://github.com/seatlayer/seatlayer-python) |
290
+ | PHP (server) | [`seatlayer/seatlayer-php`](https://github.com/seatlayer/seatlayer-php) |
291
+ | Java (server) | [`io.seatlayer:seatlayer-java`](https://github.com/seatlayer/seatlayer-java) |
292
+ | Go (server) | [`github.com/seatlayer/seatlayer-go`](https://github.com/seatlayer/seatlayer-go) |
293
+ | .NET (server) | [`SeatLayer`](https://github.com/seatlayer/seatlayer-dotnet) |
294
+
295
+ ## Development
296
+
297
+ ```bash
298
+ bundle install
299
+ bundle exec rubocop
300
+ bundle exec rspec
301
+ ```
302
+
303
+ ## License
304
+
305
+ MIT
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeatLayer
4
+ # Base class for every SeatLayer error.
5
+ class Error < StandardError; end
6
+
7
+ # Raised for any non-2xx response.
8
+ #
9
+ # The API answers failures with {"error":…, "code":…, "message":…} and a status.
10
+ # Surfacing that as one opaque exception leaves every caller string-matching on
11
+ # +error+. The subclasses below are the ones an integration actually branches
12
+ # on — a sold-out seat is a business outcome that belongs in a rescue of its
13
+ # own, not lumped in with a bad key.
14
+ class APIError < Error
15
+ # @return [Integer] HTTP status the API answered with.
16
+ attr_reader :status
17
+ # @return [String] machine-readable slug: body "code", falling back to "error".
18
+ attr_reader :code
19
+ # @return [Hash] the decoded error body, for fields this SDK does not model.
20
+ attr_reader :body
21
+ # @return [String, nil] correlation id from X-Request-ID. Quote it in support requests.
22
+ attr_reader :request_id
23
+
24
+ def initialize(status:, code:, body:, request_id:, message: nil)
25
+ @status = status
26
+ @code = code
27
+ @body = body
28
+ @request_id = request_id
29
+ super(message || "SeatLayer API error #{status} (#{code})")
30
+ end
31
+
32
+ # Build the most specific error class for a response.
33
+ def self.from_response(status:, body:, request_id:, retry_after:)
34
+ code = presence(body["code"]) || presence(body["error"]) || "unknown_error"
35
+ attrs = { status: status, code: code, body: body, request_id: request_id,
36
+ message: presence(body["message"]) }
37
+
38
+ case status
39
+ when 401, 403 then AuthError.new(**attrs)
40
+ when 404 then NotFoundError.new(**attrs)
41
+ when 409 then ConflictError.new(**attrs)
42
+ when 422 then ValidationError.new(**attrs)
43
+ when 429 then RateLimitError.new(**attrs, retry_after: retry_after)
44
+ else APIError.new(**attrs)
45
+ end
46
+ end
47
+
48
+ def self.presence(value)
49
+ value.is_a?(String) && !value.empty? ? value : nil
50
+ end
51
+ private_class_method :presence
52
+ end
53
+
54
+ # 401/403 — bad key, revoked key, or a live key used against a test event.
55
+ class AuthError < APIError
56
+ # The key's mode and the event's mode disagree. The most common cause of a
57
+ # "works locally, 403s in production" report.
58
+ def mode_mismatch?
59
+ code == "mode_mismatch"
60
+ end
61
+ end
62
+
63
+ # 404 — including another organisation's resource.
64
+ #
65
+ # Asking for something owned by a different organisation answers 404, never
66
+ # 403: a 403 would confirm the resource exists, which is not something one
67
+ # customer should be able to learn about another.
68
+ class NotFoundError < APIError; end
69
+
70
+ # 409 — the seats moved under you.
71
+ #
72
+ # Normal in ticketing, not exceptional: two buyers wanted the same seat and one
73
+ # lost.
74
+ class ConflictError < APIError
75
+ # @return [Array<Hash>] per-object conflicts, when the endpoint reports them.
76
+ def conflicts
77
+ value = body["conflicts"]
78
+ value.is_a?(Array) ? value.grep(Hash) : []
79
+ end
80
+
81
+ # Best-available could not find enough free inventory.
82
+ def sold_out?
83
+ %w[sold_out not_enough_together].include?(body["reason"])
84
+ end
85
+ end
86
+
87
+ # 422 — the request was understood and rejected.
88
+ class ValidationError < APIError; end
89
+
90
+ # 429. +retry_after+ prefers the header over the JSON field.
91
+ class RateLimitError < APIError
92
+ # @return [Float] seconds to wait before retrying.
93
+ attr_reader :retry_after
94
+
95
+ def initialize(retry_after:, **attrs)
96
+ @retry_after = retry_after
97
+ super(**attrs)
98
+ end
99
+ end
100
+
101
+ # The request never got an answer: DNS, TLS, socket, or timeout.
102
+ class ConnectionError < Error; end
103
+
104
+ # The webhook delivery did not come from SeatLayer. Respond 400; do not
105
+ # process it.
106
+ class WebhookVerificationError < Error; end
107
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "uri"
7
+
8
+ module SeatLayer
9
+ # The transport: auth, idempotency, retry, and error mapping.
10
+ #
11
+ # Built on net/http from the standard library rather than Faraday or HTTParty.
12
+ # A server SDK that drags in an HTTP stack becomes a supply-chain surface for
13
+ # every customer who installs it, and can conflict with whatever the host
14
+ # application already uses.
15
+ class HTTPClient
16
+ DEFAULT_BASE_URL = "https://api.seatlayer.io"
17
+ DEFAULT_MAX_RETRIES = 3
18
+ DEFAULT_TIMEOUT = 30.0
19
+
20
+ # The API's own charset for Idempotency-Key.
21
+ IDEMPOTENCY_KEY_PATTERN = /\A[A-Za-z0-9._:-]{1,128}\z/
22
+
23
+ # @return [String] "live", "test", or "unknown", derived from the key prefix.
24
+ attr_reader :mode
25
+ attr_reader :base_url
26
+
27
+ def initialize(secret_key:, base_url: DEFAULT_BASE_URL, max_retries: DEFAULT_MAX_RETRIES,
28
+ timeout: DEFAULT_TIMEOUT, transport: nil)
29
+ raise ArgumentError, "A SeatLayer secret key is required." if secret_key.nil? || secret_key.empty?
30
+
31
+ # Caught here rather than as a 401 three round-trips later. The pk_ case
32
+ # gets its own message: it is the one people paste by mistake.
33
+ if secret_key.start_with?("pk_")
34
+ raise ArgumentError,
35
+ "That is a publishable key. The server SDK needs a secret key (sk_live_… or sk_test_…)."
36
+ end
37
+ unless secret_key.start_with?("sk_")
38
+ raise ArgumentError, "A SeatLayer secret key starts with sk_live_ or sk_test_."
39
+ end
40
+
41
+ @secret_key = secret_key
42
+ @base_url = base_url.sub(%r{/+\z}, "")
43
+ @max_retries = max_retries
44
+ @timeout = timeout
45
+ @transport = transport
46
+ @mode = if secret_key.start_with?("sk_test_") then "test"
47
+ elsif secret_key.start_with?("sk_live_") then "live"
48
+ else "unknown"
49
+ end
50
+ end
51
+
52
+ def self.validate_idempotency_key!(key)
53
+ return if IDEMPOTENCY_KEY_PATTERN.match?(key)
54
+
55
+ raise ArgumentError,
56
+ "Invalid Idempotency-Key #{key.inspect}: allowed characters are " \
57
+ "A-Z a-z 0-9 . _ : - and the length must be 1-128."
58
+ end
59
+
60
+ # Percent-encode a path segment, including slashes.
61
+ def self.encode(segment)
62
+ URI.encode_www_form_component(segment.to_s).gsub("+", "%20")
63
+ end
64
+
65
+ def request(method, path, query: nil, body: nil, idempotency_key: nil)
66
+ url = @base_url + path
67
+ if query
68
+ pairs = query.compact
69
+ url += "?#{URI.encode_www_form(pairs)}" unless pairs.empty?
70
+ end
71
+
72
+ headers = {
73
+ "Authorization" => "Bearer #{@secret_key}",
74
+ "Accept" => "application/json",
75
+ "User-Agent" => "seatlayer-ruby"
76
+ }
77
+ payload = nil
78
+ if body
79
+ payload = JSON.generate(body)
80
+ headers["Content-Type"] = "application/json"
81
+ end
82
+
83
+ # Every mutation carries one. A retried POST that creates a second hold is
84
+ # worse than a failed POST, and the caller cannot tell from outside — so
85
+ # the SDK, which knows it retried, is the right place to guarantee it.
86
+ unless %w[GET HEAD].include?(method)
87
+ key = idempotency_key || SecureRandom.uuid
88
+ self.class.validate_idempotency_key!(key)
89
+ headers["Idempotency-Key"] = key
90
+ end
91
+
92
+ execute(method, url, headers, payload)
93
+ end
94
+
95
+ def get(path, query = nil)
96
+ request("GET", path, query: query)
97
+ end
98
+
99
+ def post(path, body = nil, idempotency_key: nil)
100
+ request("POST", path, body: body, idempotency_key: idempotency_key)
101
+ end
102
+
103
+ def put(path, body)
104
+ request("PUT", path, body: body)
105
+ end
106
+
107
+ def patch(path, body)
108
+ request("PATCH", path, body: body)
109
+ end
110
+
111
+ def delete(path)
112
+ request("DELETE", path)
113
+ end
114
+
115
+ private
116
+
117
+ # The retry loop, extracted from #request so each piece stays readable: the
118
+ # public method builds the call, this one decides how many times to make it.
119
+ def execute(method, url, headers, payload)
120
+ last_error = nil
121
+
122
+ @max_retries.times do |attempt|
123
+ begin
124
+ response = send_request(method, url, headers, payload)
125
+ rescue ConnectionError => e
126
+ raise e if attempt >= @max_retries - 1
127
+
128
+ last_error = e
129
+ sleep(backoff(attempt, nil))
130
+ next
131
+ end
132
+
133
+ status = response[:status]
134
+ return parse_success(status, response[:body]) if status >= 200 && status < 300
135
+
136
+ error_body = decode_error_body(response[:body])
137
+ retry_after = parse_retry_after(response[:headers], error_body)
138
+
139
+ if retryable?(status) && attempt < @max_retries - 1
140
+ sleep(backoff(attempt, status == 429 ? retry_after : nil))
141
+ next
142
+ end
143
+
144
+ raise APIError.from_response(
145
+ status: status, body: error_body,
146
+ request_id: response[:headers]["x-request-id"], retry_after: retry_after
147
+ )
148
+ end
149
+
150
+ raise last_error || ConnectionError.new("Request failed with no attempts made.")
151
+ end
152
+
153
+ # A proxy or WAF can answer with HTML; that must not become a parse crash
154
+ # that hides the real status from the caller.
155
+ def decode_error_body(body)
156
+ parsed = JSON.parse(body.to_s)
157
+ parsed.is_a?(Hash) ? parsed : {}
158
+ rescue JSON::ParserError
159
+ {}
160
+ end
161
+
162
+ def parse_success(status, body)
163
+ return {} if status == 204 || body.nil? || body.empty?
164
+
165
+ JSON.parse(body)
166
+ end
167
+
168
+ def send_request(method, url, headers, payload)
169
+ return @transport.call(method, url, headers, payload) if @transport
170
+
171
+ uri = URI.parse(url)
172
+ request_class = Net::HTTP.const_get(method.capitalize)
173
+ http_request = request_class.new(uri)
174
+ headers.each { |name, value| http_request[name] = value }
175
+ http_request.body = payload if payload
176
+
177
+ response = Net::HTTP.start(uri.hostname, uri.port,
178
+ use_ssl: uri.scheme == "https",
179
+ open_timeout: @timeout, read_timeout: @timeout) do |http|
180
+ http.request(http_request)
181
+ end
182
+
183
+ normalised = {}
184
+ response.each_header { |name, value| normalised[name.downcase] = value }
185
+ { status: response.code.to_i, body: response.body, headers: normalised }
186
+ rescue StandardError => e
187
+ raise ConnectionError, "Request to #{method} #{url} failed: #{e.message}"
188
+ end
189
+
190
+ # Retry only what is safe to retry. 429 and 5xx are transient by definition;
191
+ # a 4xx is the API saying the request itself is wrong, and retrying only
192
+ # burns rate-limit budget and delays the error the caller needs to see.
193
+ def retryable?(status)
194
+ status == 429 || status == 408 || (status >= 500 && status < 600)
195
+ end
196
+
197
+ # Exponential with full jitter, so a fleet of workers limited at the same
198
+ # moment does not retry in lockstep and re-limit itself.
199
+ def backoff(attempt, retry_after)
200
+ return retry_after if retry_after
201
+
202
+ ceiling = [8.0, 0.25 * (2**attempt)].min
203
+ rand * ceiling
204
+ end
205
+
206
+ def parse_retry_after(headers, body)
207
+ header = headers["retry-after"]
208
+ if header
209
+ seconds = Float(header, exception: false)
210
+ return seconds if seconds && seconds >= 0
211
+ end
212
+ # Fall back to the JSON field for routes that predate the headers.
213
+ field = body["retryAfterSeconds"]
214
+ field.is_a?(Numeric) ? field.to_f : 1.0
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeatLayer
4
+ # Holds, booking, blocking and availability.
5
+ #
6
+ # Two complete flows, both first-class:
7
+ #
8
+ # browser holds → retrieve_hold for authoritative pricing → charge → book(hold_id:)
9
+ # backend books labels directly — box office, phone sales, comps
10
+ #
11
+ # Never price from what the browser tells you. +retrieve_hold+ is the
12
+ # authoritative answer, which is why it is a separate call.
13
+ class Inventory < Resource
14
+ def hold(event_key, labels: nil, selections: nil, ttl_ms: nil,
15
+ replace_hold_id: nil, idempotency_key: nil)
16
+ body = compact({ "labels" => labels, "selections" => selections,
17
+ "ttlMs" => ttl_ms, "replaceHoldId" => replace_hold_id })
18
+ @client.post(path(event_key, "/hold"), body, idempotency_key: idempotency_key)
19
+ end
20
+
21
+ # Ask us to pick the best free objects and hold them.
22
+ #
23
+ # The picker is the one the buyer widget uses, so a phone order and a web
24
+ # order get the same answer for the same inventory. A +qty+ above the server
25
+ # cap is clamped, not rejected.
26
+ def hold_best_available(event_key, qty:, category_key: nil, zone_id: nil,
27
+ ttl_ms: nil, idempotency_key: nil)
28
+ body = compact({ "qty" => qty, "categoryKey" => category_key,
29
+ "zoneId" => zone_id, "ttlMs" => ttl_ms })
30
+ @client.post(path(event_key, "/best-available"), body, idempotency_key: idempotency_key)
31
+ end
32
+
33
+ # Pick and book in one call — the box-office shape.
34
+ #
35
+ # Prefer this over hold-then-book when payment is already taken: a failure
36
+ # between two calls would strand inventory until the TTL expired.
37
+ def book_best_available(event_key, qty:, booking_ref:, category_key: nil,
38
+ zone_id: nil, idempotency_key: nil)
39
+ body = compact({ "qty" => qty, "bookingRef" => booking_ref,
40
+ "categoryKey" => category_key, "zoneId" => zone_id })
41
+ @client.post(path(event_key, "/best-available-book"), body, idempotency_key: idempotency_key)
42
+ end
43
+
44
+ # Push an active hold's expiry out by a fresh window before it lapses.
45
+ #
46
+ # Use this rather than release-and-re-hold when an order takes longer than
47
+ # the checkout window — invoiced sales, a phone order on hold. Releasing
48
+ # first hands the seats to whoever is racing for them in between. A hold that
49
+ # is gone, expired, or at its renewal cap answers 409 +cannot_extend+.
50
+ def extend_hold(event_key, hold_id, ttl_ms: nil)
51
+ @client.post(path(event_key, "/extend"), compact({ "holdId" => hold_id, "ttlMs" => ttl_ms }))
52
+ end
53
+
54
+ # Authoritative items and prices. Charge from this, not the browser.
55
+ def retrieve_hold(event_key, hold_id)
56
+ @client.get(path(event_key, "/holds/#{encode(hold_id)}"))
57
+ end
58
+
59
+ def release(event_key, labels:, hold_id:)
60
+ @client.post(path(event_key, "/release"), { "labels" => labels, "holdId" => hold_id })
61
+ end
62
+
63
+ def book(event_key, hold_id: nil, labels: nil, booking_ref: nil, idempotency_key: nil)
64
+ body = compact({ "holdId" => hold_id, "labels" => labels, "bookingRef" => booking_ref })
65
+ @client.post(path(event_key, "/book"), body, idempotency_key: idempotency_key)
66
+ end
67
+
68
+ def box_office_book(event_key, labels:, booking_ref:, idempotency_key: nil)
69
+ @client.post(path(event_key, "/box-book"),
70
+ { "labels" => labels, "bookingRef" => booking_ref },
71
+ idempotency_key: idempotency_key)
72
+ end
73
+
74
+ # Reverse a booking. Requires a key with cancel authority.
75
+ def unbook(event_key, labels:)
76
+ @client.post(path(event_key, "/unbook"), { "labels" => labels })
77
+ end
78
+
79
+ # Hold inventory back from sale (house seats, production holds).
80
+ def block(event_key, labels:)
81
+ @client.post(path(event_key, "/block"), { "labels" => labels })
82
+ end
83
+
84
+ def unblock(event_key, labels:)
85
+ @client.post(path(event_key, "/unblock"), { "labels" => labels })
86
+ end
87
+
88
+ def unblock_all(event_key)
89
+ @client.post(path(event_key, "/unblock-all"))
90
+ end
91
+
92
+ def retrieve_availability(event_key)
93
+ @client.get(path(event_key, "/availability"))
94
+ end
95
+
96
+ def update_availability(event_key, fields)
97
+ @client.post(path(event_key, "/availability"), fields)
98
+ end
99
+
100
+ private
101
+
102
+ def path(event_key, suffix)
103
+ "/v1/events/#{encode(event_key)}#{suffix}"
104
+ end
105
+ end
106
+
107
+ # Short-lived, origin-bound browser tokens.
108
+ #
109
+ # The governing rule: the SDK mints tokens, widgets consume them. Your secret
110
+ # key never reaches a browser.
111
+ class Sessions < Resource
112
+ CAPABILITIES = ["event:view", "event:block", "event:cancel", "event:reports"].freeze
113
+
114
+ # Mint a manage-session token for the control room.
115
+ #
116
+ # +capabilities+ is required here even though the API defaults it. That
117
+ # default grants all four — including event:cancel, which un-books paid
118
+ # inventory. Granting the ability to reverse sales by forgetting an argument
119
+ # is not a default worth inheriting.
120
+ def create_manage_session(event_key, allowed_origin:, capabilities:, expires_in_seconds: nil)
121
+ if capabilities.nil? || capabilities.empty?
122
+ raise ArgumentError,
123
+ "capabilities is required: pass the smallest set the page needs, e.g. " \
124
+ '["event:view"]. Omitting it server-side grants event:cancel, ' \
125
+ "which can reverse paid bookings."
126
+ end
127
+
128
+ body = compact({ "allowedOrigin" => allowed_origin, "capabilities" => capabilities,
129
+ "expiresInSeconds" => expires_in_seconds })
130
+ @client.post("/v1/events/#{encode(event_key)}/manage-sessions", body)
131
+ end
132
+
133
+ def revoke_manage_session(event_key, session_id)
134
+ @client.delete("/v1/events/#{encode(event_key)}/manage-sessions/#{encode(session_id)}")
135
+ end
136
+
137
+ # Mint a designer token so an organiser can edit a chart inside your own UI.
138
+ # Requires a chart id that already exists — create or copy one first.
139
+ def create_designer_session(workspace_id:, chart_id:, allowed_origin:,
140
+ authority: nil, mode: nil, expires_in_seconds: nil)
141
+ body = compact({ "workspaceId" => workspace_id, "chartId" => chart_id,
142
+ "allowedOrigin" => allowed_origin, "authority" => authority,
143
+ "mode" => mode, "expiresInSeconds" => expires_in_seconds })
144
+ @client.post("/v1/designer/sessions", body)
145
+ end
146
+
147
+ def revoke_designer_session(session_id)
148
+ @client.delete("/v1/designer/sessions/#{encode(session_id)}")
149
+ end
150
+ end
151
+
152
+ # Manage webhook subscriptions. To VERIFY a delivery, see SeatLayer::Webhook.
153
+ class Webhooks < Resource
154
+ def list
155
+ @client.get("/v1/webhooks")
156
+ end
157
+
158
+ def create(url:, events:)
159
+ @client.post("/v1/webhooks", { "url" => url, "events" => events })
160
+ end
161
+
162
+ def update(webhook_id, fields)
163
+ @client.patch("/v1/webhooks/#{encode(webhook_id)}", fields)
164
+ end
165
+
166
+ def delete(webhook_id)
167
+ @client.delete("/v1/webhooks/#{encode(webhook_id)}")
168
+ end
169
+
170
+ def list_deliveries(webhook_id)
171
+ @client.get("/v1/webhooks/#{encode(webhook_id)}/deliveries")
172
+ end
173
+ end
174
+
175
+ # Workspaces isolate one tenant's charts and events from another's.
176
+ class Workspaces < Resource
177
+ def list
178
+ @client.get("/v1/workspaces")
179
+ end
180
+
181
+ def create(name:, external_ref: nil, idempotency_key: nil)
182
+ body = compact({ "name" => name, "externalRef" => external_ref })
183
+ @client.post("/v1/workspaces", body, idempotency_key: idempotency_key)
184
+ end
185
+
186
+ def retrieve(workspace_id)
187
+ @client.get("/v1/workspaces/#{encode(workspace_id)}")
188
+ end
189
+
190
+ # Rename, re-reference, or disable a workspace.
191
+ #
192
+ # The organisation's default workspace cannot be disabled — the API answers
193
+ # 409 +default_workspace_required+. Promote another one first.
194
+ def update(workspace_id, fields)
195
+ @client.patch("/v1/workspaces/#{encode(workspace_id)}", fields)
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeatLayer
4
+ # Shared plumbing for the resource namespaces.
5
+ class Resource
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ private
11
+
12
+ # Build a request body, dropping nils so optional arguments stay optional
13
+ # rather than being sent as explicit JSON null.
14
+ def compact(hash)
15
+ hash.compact
16
+ end
17
+
18
+ def encode(segment)
19
+ HTTPClient.encode(segment)
20
+ end
21
+ end
22
+
23
+ # Seat-map definitions that events are created from.
24
+ #
25
+ # Even when organisers draw their own venues in the embedded Designer you need
26
+ # this: +create_designer_session+ requires a chart id that already exists, so
27
+ # the usual platform flow is copy a template here, then hand over a session.
28
+ class Charts < Resource
29
+ # One page of charts. Pass +cursor+ from the previous page's "nextCursor".
30
+ def list(workspace_id: nil, external_ref: nil, archived: false, limit: nil, cursor: nil)
31
+ query = compact({ "workspaceId" => workspace_id, "externalRef" => external_ref,
32
+ "limit" => limit, "cursor" => cursor })
33
+ query["archived"] = "1" if archived
34
+ @client.get("/v1/charts", query)
35
+ end
36
+
37
+ # Every chart, paging transparently.
38
+ #
39
+ # Returns an Enumerator when no block is given, so it stays lazy — the point
40
+ # of paginating was to not hold an unbounded result set in memory, and
41
+ # returning an Array would hand that problem straight back.
42
+ #
43
+ # client.charts.list_all { |chart| ... }
44
+ # client.charts.list_all.lazy.first(5)
45
+ def list_all(**options, &block)
46
+ return enum_for(:list_all, **options) unless block_given?
47
+
48
+ cursor = nil
49
+ loop do
50
+ page = list(**options, cursor: cursor)
51
+ Array(page["charts"]).each(&block)
52
+ cursor = page["nextCursor"]
53
+ # An absent cursor terminates, so a caller looping cannot spin forever.
54
+ break if cursor.nil? || cursor.empty?
55
+ end
56
+ end
57
+
58
+ def create(name:, doc: nil, external_ref: nil, workspace_id: nil, idempotency_key: nil)
59
+ body = compact({ "name" => name, "doc" => doc,
60
+ "externalRef" => external_ref, "workspaceId" => workspace_id })
61
+ @client.post("/v1/charts", body, idempotency_key: idempotency_key)
62
+ end
63
+
64
+ def retrieve(chart_id)
65
+ @client.get("/v1/charts/#{encode(chart_id)}")
66
+ end
67
+
68
+ # Replace a chart document.
69
+ #
70
+ # +expected_updated_at+ is required for optimistic concurrency and is not
71
+ # optional here either: without it two concurrent writers silently overwrite
72
+ # each other, and a seat map is exactly the document where that loses work.
73
+ # Read it from +retrieve+ immediately before writing.
74
+ #
75
+ # The Designer is the authoring surface. Use this for bulk programmatic edits
76
+ # and migrations, not for drawing.
77
+ def update(chart_id, doc:, expected_updated_at:, name: nil)
78
+ body = compact({ "doc" => doc, "expectedUpdatedAt" => expected_updated_at, "name" => name })
79
+ @client.put("/v1/charts/#{encode(chart_id)}", body)
80
+ end
81
+
82
+ def delete(chart_id)
83
+ @client.delete("/v1/charts/#{encode(chart_id)}")
84
+ end
85
+
86
+ # Copy a chart — the usual way to provision a venue from a template.
87
+ def copy(chart_id, idempotency_key: nil)
88
+ @client.post("/v1/charts/#{encode(chart_id)}/duplicate", nil, idempotency_key: idempotency_key)
89
+ end
90
+
91
+ def archive(chart_id)
92
+ @client.post("/v1/charts/#{encode(chart_id)}/archive")
93
+ end
94
+
95
+ def unarchive(chart_id)
96
+ @client.post("/v1/charts/#{encode(chart_id)}/unarchive")
97
+ end
98
+
99
+ # Publish the draft. Events can only be created from a published chart.
100
+ def publish(chart_id)
101
+ @client.post("/v1/charts/#{encode(chart_id)}/publish")
102
+ end
103
+ end
104
+
105
+ # Event lifecycle, metadata and reports.
106
+ class Events < Resource
107
+ # One page of events.
108
+ #
109
+ # Live availability counts cost one round-trip per event server-side. They
110
+ # are on by default because most callers of a single page want them; pass
111
+ # <tt>counts: false</tt> when paging a whole catalogue.
112
+ def list(workspace_id: nil, external_ref: nil, limit: nil, cursor: nil, counts: true)
113
+ query = compact({ "workspaceId" => workspace_id, "externalRef" => external_ref,
114
+ "limit" => limit, "cursor" => cursor })
115
+ query["counts"] = "0" unless counts
116
+ @client.get("/v1/events", query)
117
+ end
118
+
119
+ # Every event, paging transparently. Counts default off here — you are
120
+ # walking the whole list, so per-event availability is rarely what you want
121
+ # and always what it costs.
122
+ def list_all(counts: false, **options, &block)
123
+ return enum_for(:list_all, counts: counts, **options) unless block_given?
124
+
125
+ cursor = nil
126
+ loop do
127
+ page = list(**options, counts: counts, cursor: cursor)
128
+ Array(page["events"]).each(&block)
129
+ cursor = page["nextCursor"]
130
+ break if cursor.nil? || cursor.empty?
131
+ end
132
+ end
133
+
134
+ def create(chart_id:, name: nil, slug: nil, starts_at: nil, venue: nil,
135
+ external_ref: nil, currency: nil, idempotency_key: nil)
136
+ body = compact({ "chartId" => chart_id, "name" => name, "slug" => slug,
137
+ "startsAt" => starts_at, "venue" => venue,
138
+ "externalRef" => external_ref, "currency" => currency })
139
+ @client.post("/v1/events", body, idempotency_key: idempotency_key)
140
+ end
141
+
142
+ def retrieve(event_key)
143
+ @client.get("/v1/events/#{encode(event_key)}")
144
+ end
145
+
146
+ def update(event_key, fields)
147
+ @client.patch("/v1/events/#{encode(event_key)}", fields)
148
+ end
149
+
150
+ def delete(event_key)
151
+ @client.delete("/v1/events/#{encode(event_key)}")
152
+ end
153
+
154
+ # Move a live event onto the latest published version of its chart.
155
+ def update_chart(event_key)
156
+ @client.post("/v1/events/#{encode(event_key)}/update-chart")
157
+ end
158
+
159
+ # Stop buyer sales. Existing holds keep their TTL.
160
+ def close(event_key)
161
+ @client.post("/v1/events/#{encode(event_key)}/close")
162
+ end
163
+
164
+ def reopen(event_key)
165
+ @client.post("/v1/events/#{encode(event_key)}/reopen")
166
+ end
167
+
168
+ def archive(event_key)
169
+ @client.post("/v1/events/#{encode(event_key)}/archive")
170
+ end
171
+
172
+ def retrieve_hold_ttl(event_key)
173
+ @client.get("/v1/events/#{encode(event_key)}/hold-ttl")
174
+ end
175
+
176
+ def update_hold_ttl(event_key, hold_ttl_ms)
177
+ @client.post("/v1/events/#{encode(event_key)}/hold-ttl", { "holdTtlMs" => hold_ttl_ms })
178
+ end
179
+
180
+ def retrieve_report(event_key)
181
+ @client.get("/v1/events/#{encode(event_key)}/report")
182
+ end
183
+
184
+ def retrieve_log(event_key)
185
+ @client.get("/v1/events/#{encode(event_key)}/log")
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeatLayer
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module SeatLayer
7
+ # Webhook signature verification.
8
+ #
9
+ # The most security-sensitive thing an integrator writes by hand, and the two
10
+ # classic mistakes are both easy to make and silent:
11
+ #
12
+ # 1. verifying against a re-serialised body, which changes bytes and fails — or
13
+ # worse, gets "fixed" by skipping verification entirely;
14
+ # 2. comparing signatures with ==, which leaks the expected value through timing.
15
+ #
16
+ # So the SDK does it, takes the RAW body, and compares in constant time.
17
+ module Webhook
18
+ module_function
19
+
20
+ # Verify a delivery and return its decoded payload.
21
+ #
22
+ # +payload+ must be the raw request body. In Rails that is
23
+ # <tt>request.raw_post</tt>; in Sinatra, <tt>request.body.read</tt>. Never a
24
+ # parsed Hash re-encoded.
25
+ #
26
+ # NOTE ON REPLAY: deliveries are signed over the body, which carries an "at"
27
+ # timestamp — but nothing enforces a freshness window, so a captured delivery
28
+ # stays valid indefinitely. Replay protection is yours: every event carries
29
+ # an occurrenceId, and the correct pattern is to record processed ids and
30
+ # ignore repeats. Do not skip this.
31
+ #
32
+ # @raise [SeatLayer::WebhookVerificationError] when the delivery is not ours
33
+ def verify(payload, signature, secret)
34
+ raise WebhookVerificationError, "A webhook signing secret is required." if secret.nil? || secret.empty?
35
+
36
+ if signature.nil? || signature.empty?
37
+ raise WebhookVerificationError, "Missing X-SeatLayer-Signature header."
38
+ end
39
+
40
+ scheme, _, provided = signature.partition("=")
41
+ if scheme != "sha256" || provided.empty?
42
+ raise WebhookVerificationError,
43
+ "Unsupported signature format #{signature.inspect}; expected \"sha256=<hex>\"."
44
+ end
45
+
46
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
47
+ # secure_compare is constant time and handles a length mismatch without
48
+ # leaking which of the two failures occurred.
49
+ unless OpenSSL.secure_compare(expected, provided)
50
+ raise WebhookVerificationError, "Webhook signature did not match."
51
+ end
52
+
53
+ begin
54
+ JSON.parse(payload)
55
+ rescue JSON::ParserError => e
56
+ raise WebhookVerificationError, "Signature verified but the body is not valid JSON: #{e.message}"
57
+ end
58
+ end
59
+ end
60
+ end
data/lib/seatlayer.rb ADDED
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "seatlayer/version"
4
+ require_relative "seatlayer/errors"
5
+ require_relative "seatlayer/http_client"
6
+ require_relative "seatlayer/resources"
7
+ require_relative "seatlayer/inventory"
8
+ require_relative "seatlayer/webhook"
9
+
10
+ # Official Ruby server SDK for the SeatLayer reserved-seating API.
11
+ #
12
+ # Server-side only: this gem authenticates with your secret key. Never load it
13
+ # anywhere a ticket buyer can reach — browser surfaces get short-lived, scoped
14
+ # tokens that you mint with SeatLayer::Sessions.
15
+ #
16
+ # client = SeatLayer::Client.new(ENV.fetch("SEATLAYER_SECRET_KEY"))
17
+ # held = client.inventory.hold_best_available("summer-gala", qty: 4)
18
+ module SeatLayer
19
+ # The SeatLayer client.
20
+ class Client
21
+ attr_reader :charts, :events, :inventory, :sessions, :webhooks, :workspaces
22
+
23
+ def initialize(secret_key, base_url: HTTPClient::DEFAULT_BASE_URL,
24
+ max_retries: HTTPClient::DEFAULT_MAX_RETRIES,
25
+ timeout: HTTPClient::DEFAULT_TIMEOUT, transport: nil)
26
+ @http = HTTPClient.new(secret_key: secret_key, base_url: base_url,
27
+ max_retries: max_retries, timeout: timeout, transport: transport)
28
+
29
+ @charts = Charts.new(@http)
30
+ @events = Events.new(@http)
31
+ @inventory = Inventory.new(@http)
32
+ @sessions = Sessions.new(@http)
33
+ @webhooks = Webhooks.new(@http)
34
+ @workspaces = Workspaces.new(@http)
35
+ end
36
+
37
+ # @return [String] "live" or "test", derived from the key prefix.
38
+ def mode
39
+ @http.mode
40
+ end
41
+
42
+ # Dependency-aware readiness probe.
43
+ def ready
44
+ @http.get("/health/ready")
45
+ end
46
+
47
+ # Escape hatch for surface this SDK does not wrap yet. Carries the same auth,
48
+ # retries, idempotency and error mapping.
49
+ def request(method, path, query: nil, body: nil, idempotency_key: nil)
50
+ @http.request(method, path, query: query, body: body, idempotency_key: idempotency_key)
51
+ end
52
+ end
53
+ end
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: seatlayer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - SeatLayer
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'Server-side Ruby client for SeatLayer: charts, events, holds, booking,
13
+ embed sessions and webhook verification, with idempotency and retries built in.'
14
+ email:
15
+ - hello@seatlayer.io
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE
22
+ - README.md
23
+ - lib/seatlayer.rb
24
+ - lib/seatlayer/errors.rb
25
+ - lib/seatlayer/http_client.rb
26
+ - lib/seatlayer/inventory.rb
27
+ - lib/seatlayer/resources.rb
28
+ - lib/seatlayer/version.rb
29
+ - lib/seatlayer/webhook.rb
30
+ homepage: https://seatlayer.io
31
+ licenses:
32
+ - MIT
33
+ metadata:
34
+ homepage_uri: https://seatlayer.io
35
+ documentation_uri: https://docs.seatlayer.io/server-sdk/install/
36
+ source_code_uri: https://github.com/seatlayer/seatlayer-ruby
37
+ changelog_uri: https://github.com/seatlayer/seatlayer-ruby/blob/main/CHANGELOG.md
38
+ bug_tracker_uri: https://github.com/seatlayer/seatlayer-ruby/issues
39
+ rubygems_mfa_required: 'true'
40
+ rdoc_options: []
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 3.0.0
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubygems_version: 3.6.9
55
+ specification_version: 4
56
+ summary: Official Ruby server SDK for the SeatLayer reserved-seating API.
57
+ test_files: []