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 +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE +21 -0
- data/README.md +305 -0
- data/lib/seatlayer/errors.rb +107 -0
- data/lib/seatlayer/http_client.rb +217 -0
- data/lib/seatlayer/inventory.rb +198 -0
- data/lib/seatlayer/resources.rb +188 -0
- data/lib/seatlayer/version.rb +5 -0
- data/lib/seatlayer/webhook.rb +60 -0
- data/lib/seatlayer.rb +53 -0
- metadata +57 -0
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,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: []
|