unitpost 0.0.0 → 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.
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "time"
6
+ require_relative "errors"
7
+ require_relative "generated/operations"
8
+
9
+ module Unitpost
10
+ # HTTP transport — the one place that talks to the Unitpost API.
11
+ #
12
+ # Hand-written (not generated): auth, the required User-Agent, idempotency,
13
+ # query building, JSON encode/decode, and turning a non-2xx response into a
14
+ # typed Unitpost::Error. Resource methods (resources.rb) call through here.
15
+ class HttpClient
16
+ SDK_VERSION = Unitpost::VERSION
17
+ # The live API host. Matches the OpenAPI servers[].url and the app's own
18
+ # brand.ts FALLBACK_APP_ORIGIN. SINGLE HOST: the API lives on the same origin
19
+ # as the app (www.unitpost.com) under /api/v1 — no api. subdomain. Override
20
+ # per-client with base_url / UNITPOST_BASE_URL.
21
+ DEFAULT_BASE_URL = "https://www.unitpost.com"
22
+
23
+ def initialize(api_key: nil, base_url: nil, timeout: 30, headers: {}, adapter: nil, stubs: nil,
24
+ max_retries: 2, retry_base_delay: 0.5, max_retry_delay: 20.0, sleeper: nil)
25
+ key = api_key || ENV.fetch("UNITPOST_API_KEY", nil)
26
+ if key.nil? || key.empty?
27
+ raise Unitpost::Error.new(
28
+ code: "missing_api_key",
29
+ message: "No API key provided. Pass Unitpost::Client.new(api_key:) or set the UNITPOST_API_KEY environment variable. Create a key at https://www.unitpost.com → Settings → API keys. Full reference: https://www.unitpost.com/docs",
30
+ status: 0
31
+ )
32
+ end
33
+ @api_key = key
34
+ base = (base_url || ENV["UNITPOST_BASE_URL"] || DEFAULT_BASE_URL).sub(%r{/\z}, "")
35
+ @prefix = "/api/#{Unitpost::Generated::API_VERSION}"
36
+ @extra_headers = headers
37
+ # Automatic retry policy for transient failures (429, 5xx, timeouts,
38
+ # network errors). max_retries: 0 disables it. See SECURITY.md §6.
39
+ @max_retries = [max_retries, 0].max
40
+ @retry_base_delay = retry_base_delay
41
+ @max_retry_delay = max_retry_delay
42
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
43
+
44
+ @conn = Faraday.new(url: base) do |f|
45
+ f.options.timeout = timeout
46
+ if stubs
47
+ f.adapter(:test, stubs)
48
+ else
49
+ f.adapter(adapter || Faraday.default_adapter)
50
+ end
51
+ end
52
+ end
53
+
54
+ # Make a request, retrying transient failures (429, 5xx, timeouts, network
55
+ # errors) with backoff. A 429/503 Retry-After is honored (clamped to
56
+ # max_retry_delay); otherwise exponential backoff with full jitter. 4xx
57
+ # (other than 429) is never retried. A request is only retried when it's safe
58
+ # to replay: a GET, or a write carrying an Idempotency-Key (deduped
59
+ # server-side). A keyless write is never auto-retried, so a lost response
60
+ # can't double-send.
61
+ def request(http_method, path, query: nil, body: nil, idempotency_key: nil)
62
+ retry_safe = http_method == "GET" || !idempotency_key.nil?
63
+ attempt = 0
64
+ loop do
65
+ result, retry_after = attempt_request(
66
+ http_method, path, query: query, body: body, idempotency_key: idempotency_key
67
+ )
68
+ err = result.error
69
+ retryable = !err.nil? && (err.status.zero? || retryable_status?(err.status))
70
+ return result if err.nil? || !retryable || !retry_safe || attempt >= @max_retries
71
+
72
+ @sleeper.call(backoff_delay(attempt, retry_after))
73
+ attempt += 1
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def retryable_status?(status)
80
+ status == 429 || (status >= 500 && status <= 599)
81
+ end
82
+
83
+ def backoff_delay(attempt, retry_after)
84
+ return [retry_after, @max_retry_delay].min unless retry_after.nil?
85
+
86
+ ceiling = [@retry_base_delay * (2**attempt), @max_retry_delay].min
87
+ rand * ceiling
88
+ end
89
+
90
+ # Parse a Retry-After header (delta-seconds or HTTP-date) into seconds.
91
+ def parse_retry_after(value)
92
+ return nil if value.nil? || value.to_s.empty?
93
+
94
+ stripped = value.to_s.strip
95
+ return [stripped.to_f, 0.0].max if stripped.match?(/\A\d+(\.\d+)?\z/)
96
+
97
+ begin
98
+ delta = Time.httpdate(stripped).to_f - Time.now.to_f
99
+ [delta, 0.0].max
100
+ rescue ArgumentError
101
+ nil
102
+ end
103
+ end
104
+
105
+ def attempt_request(http_method, path, query: nil, body: nil, idempotency_key: nil)
106
+ headers = {
107
+ "Authorization" => "Bearer #{@api_key}",
108
+ "User-Agent" => "unitpost-ruby/#{SDK_VERSION}",
109
+ "Accept" => "application/json"
110
+ }.merge(@extra_headers)
111
+ headers["Idempotency-Key"] = idempotency_key if idempotency_key
112
+
113
+ params = (query || {}).reject { |_, v| v.nil? }
114
+
115
+ begin
116
+ response = @conn.run_request(http_method.downcase.to_sym, "#{@prefix}#{path}", nil, headers) do |req|
117
+ req.params.update(params) unless params.empty?
118
+ if body && http_method != "GET"
119
+ req.headers["Content-Type"] = "application/json"
120
+ req.body = JSON.generate(body)
121
+ end
122
+ end
123
+ rescue Faraday::TimeoutError
124
+ return [Result.new(error: Unitpost::Error.new(code: "timeout", message: "Request timed out.", status: 0)), nil]
125
+ rescue Faraday::Error => e
126
+ return [Result.new(error: Unitpost::Error.new(code: "network_error", message: e.message, status: 0)), nil]
127
+ end
128
+
129
+ request_id = response.headers["x-request-id"]
130
+ parsed = parse_body(response.body)
131
+
132
+ unless (200..299).cover?(response.status)
133
+ err = parsed.is_a?(Hash) ? (parsed["error"] || {}) : {}
134
+ details = err["details"].is_a?(Array) ? err["details"] : nil
135
+ return [
136
+ Result.new(
137
+ error: Unitpost::Error.new(
138
+ code: err["code"] || "http_error",
139
+ message: err["message"] || "Request failed with status #{response.status}.",
140
+ status: response.status,
141
+ request_id: request_id,
142
+ details: details
143
+ )
144
+ ),
145
+ parse_retry_after(response.headers["retry-after"])
146
+ ]
147
+ end
148
+
149
+ [Result.new(data: parsed), nil]
150
+ end
151
+
152
+ def parse_body(raw)
153
+ return nil if raw.nil? || raw.to_s.empty?
154
+
155
+ JSON.parse(raw)
156
+ rescue JSON::ParserError
157
+ nil
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,464 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "securerandom"
5
+
6
+ module Unitpost
7
+ # Resource surface — the hand-written, ergonomic layer (our own style).
8
+ #
9
+ # Each resource is a small class with explicit methods mirroring the Node and
10
+ # Python SDKs (emails.send, contacts.list/create/get/update/delete, ...). The
11
+ # codegen "surface coverage" check fails CI if a spec operation has no method
12
+ # here. Every method returns a Unitpost::Result and never raises for an API
13
+ # error. Bodies/params are plain Hashes.
14
+ module Resources
15
+ class Base
16
+ def initialize(http)
17
+ @http = http
18
+ end
19
+
20
+ private
21
+
22
+ def enc(segment)
23
+ CGI.escape(segment.to_s)
24
+ end
25
+
26
+ def list_params(limit:, after:, before:, **extra)
27
+ { limit: limit, after: after, before: before }.merge(extra)
28
+ end
29
+
30
+ def paginate(path, params)
31
+ Enumerator.new do |yielder|
32
+ after = params[:after]
33
+ loop do
34
+ page_params = params.merge(after: after)
35
+ result = @http.request("GET", path, query: page_params)
36
+ raise result.error if result.error
37
+
38
+ page = result.data || {}
39
+ rows = page["data"] || []
40
+ rows.each { |row| yielder << row }
41
+ break if !page["has_more"] || rows.empty?
42
+
43
+ after = rows.last["id"]
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ class Emails < Base
50
+ # Send or schedule a transactional email. When no idempotency_key is
51
+ # given the SDK generates one so a transient retry can't double-send; pass
52
+ # your own to dedupe across separate send calls.
53
+ def send(body, idempotency_key: nil)
54
+ @http.request("POST", "/emails", body: body, idempotency_key: idempotency_key || SecureRandom.uuid)
55
+ end
56
+
57
+ # Send up to 100 emails in one request (all-or-nothing validation). Like
58
+ # +send+, when no idempotency_key is given the SDK generates one so a
59
+ # transient retry is safe: the server dedupes the batch on the key and
60
+ # replays the original result instead of re-sending. Pass your own to
61
+ # dedupe across separate batch calls.
62
+ def batch(body, idempotency_key: nil)
63
+ @http.request("POST", "/emails/batch", body: body, idempotency_key: idempotency_key || SecureRandom.uuid)
64
+ end
65
+
66
+ def get_batch(id)
67
+ @http.request("GET", "/emails/batches/#{enc(id)}")
68
+ end
69
+
70
+ def cancel_batch(id)
71
+ @http.request("POST", "/emails/batches/#{enc(id)}/cancel")
72
+ end
73
+
74
+ def list(limit: nil, after: nil, before: nil, **filters)
75
+ @http.request("GET", "/emails", query: list_params(limit: limit, after: after, before: before, **filters))
76
+ end
77
+
78
+ def list_all(**params)
79
+ paginate("/emails", params)
80
+ end
81
+
82
+ def get(id)
83
+ @http.request("GET", "/emails/#{enc(id)}")
84
+ end
85
+
86
+ def update(id, body)
87
+ @http.request("PATCH", "/emails/#{enc(id)}", body: body)
88
+ end
89
+
90
+ # Deliverability/engagement aggregates over a rolling window (days: 1–365,
91
+ # defaults to 30 server-side).
92
+ def stats(days: nil)
93
+ @http.request("GET", "/emails/stats", query: { days: days })
94
+ end
95
+
96
+ def received_list(limit: nil, after: nil, before: nil)
97
+ @http.request("GET", "/emails/received", query: list_params(limit: limit, after: after, before: before))
98
+ end
99
+
100
+ def received_get(id)
101
+ @http.request("GET", "/emails/received/#{enc(id)}")
102
+ end
103
+
104
+ def received_attachment_url(id, attachment_id)
105
+ @http.request("GET", "/emails/received/#{enc(id)}/attachments/#{enc(attachment_id)}")
106
+ end
107
+ end
108
+
109
+ class Contacts < Base
110
+ def list(limit: nil, after: nil, before: nil)
111
+ @http.request("GET", "/contacts", query: list_params(limit: limit, after: after, before: before))
112
+ end
113
+
114
+ def list_all(**params)
115
+ paginate("/contacts", params)
116
+ end
117
+
118
+ def create(body)
119
+ @http.request("POST", "/contacts", body: body)
120
+ end
121
+
122
+ def get(id_or_email)
123
+ @http.request("GET", "/contacts/#{enc(id_or_email)}")
124
+ end
125
+
126
+ def update(id_or_email, body)
127
+ @http.request("PATCH", "/contacts/#{enc(id_or_email)}", body: body)
128
+ end
129
+
130
+ def delete(id_or_email)
131
+ @http.request("DELETE", "/contacts/#{enc(id_or_email)}")
132
+ end
133
+
134
+ def import(body)
135
+ @http.request("POST", "/contacts/imports", body: body)
136
+ end
137
+
138
+ def list_imports(limit: nil, after: nil, before: nil)
139
+ @http.request("GET", "/contacts/imports", query: list_params(limit: limit, after: after, before: before))
140
+ end
141
+
142
+ def get_import(id)
143
+ @http.request("GET", "/contacts/imports/#{enc(id)}")
144
+ end
145
+ end
146
+
147
+ class ContactFields < Base
148
+ def list(limit: nil, after: nil, before: nil)
149
+ @http.request("GET", "/contact-fields", query: list_params(limit: limit, after: after, before: before))
150
+ end
151
+
152
+ def list_all(**params)
153
+ paginate("/contact-fields", params)
154
+ end
155
+
156
+ def create(body)
157
+ @http.request("POST", "/contact-fields", body: body)
158
+ end
159
+
160
+ def get(id)
161
+ @http.request("GET", "/contact-fields/#{enc(id)}")
162
+ end
163
+
164
+ def update(id, body)
165
+ @http.request("PATCH", "/contact-fields/#{enc(id)}", body: body)
166
+ end
167
+
168
+ def delete(id)
169
+ @http.request("DELETE", "/contact-fields/#{enc(id)}")
170
+ end
171
+
172
+ def rename(id, body)
173
+ @http.request("POST", "/contact-fields/#{enc(id)}/rename", body: body)
174
+ end
175
+ end
176
+
177
+ class Segments < Base
178
+ def list(limit: nil, after: nil, before: nil)
179
+ @http.request("GET", "/segments", query: list_params(limit: limit, after: after, before: before))
180
+ end
181
+
182
+ def list_all(**params)
183
+ paginate("/segments", params)
184
+ end
185
+
186
+ def create(body)
187
+ @http.request("POST", "/segments", body: body)
188
+ end
189
+
190
+ def get(id)
191
+ @http.request("GET", "/segments/#{enc(id)}")
192
+ end
193
+
194
+ def update(id, body)
195
+ @http.request("PATCH", "/segments/#{enc(id)}", body: body)
196
+ end
197
+
198
+ def delete(id)
199
+ @http.request("DELETE", "/segments/#{enc(id)}")
200
+ end
201
+
202
+ def list_members(id, limit: nil, after: nil, before: nil)
203
+ @http.request("GET", "/segments/#{enc(id)}/contacts", query: list_params(limit: limit, after: after, before: before))
204
+ end
205
+
206
+ def add_member(id, body)
207
+ @http.request("POST", "/segments/#{enc(id)}/contacts", body: body)
208
+ end
209
+
210
+ def remove_member(id, contact)
211
+ @http.request("DELETE", "/segments/#{enc(id)}/contacts/#{enc(contact)}")
212
+ end
213
+ end
214
+
215
+ class Topics < Base
216
+ def list(limit: nil, after: nil, before: nil)
217
+ @http.request("GET", "/topics", query: list_params(limit: limit, after: after, before: before))
218
+ end
219
+
220
+ def list_all(**params)
221
+ paginate("/topics", params)
222
+ end
223
+
224
+ def create(body)
225
+ @http.request("POST", "/topics", body: body)
226
+ end
227
+
228
+ def get(id)
229
+ @http.request("GET", "/topics/#{enc(id)}")
230
+ end
231
+
232
+ def update(id, body)
233
+ @http.request("PATCH", "/topics/#{enc(id)}", body: body)
234
+ end
235
+
236
+ def delete(id)
237
+ @http.request("DELETE", "/topics/#{enc(id)}")
238
+ end
239
+
240
+ def list_topics(contact_id, limit: nil, after: nil, before: nil)
241
+ @http.request("GET", "/contacts/#{enc(contact_id)}/topics", query: list_params(limit: limit, after: after, before: before))
242
+ end
243
+
244
+ def set_topic(contact_id, body)
245
+ @http.request("POST", "/contacts/#{enc(contact_id)}/topics", body: body)
246
+ end
247
+ end
248
+
249
+ class Campaigns < Base
250
+ def list(limit: nil, after: nil, before: nil)
251
+ @http.request("GET", "/campaigns", query: list_params(limit: limit, after: after, before: before))
252
+ end
253
+
254
+ def list_all(**params)
255
+ paginate("/campaigns", params)
256
+ end
257
+
258
+ def create(body)
259
+ @http.request("POST", "/campaigns", body: body)
260
+ end
261
+
262
+ def get(id)
263
+ @http.request("GET", "/campaigns/#{enc(id)}")
264
+ end
265
+
266
+ def update(id, body)
267
+ @http.request("PATCH", "/campaigns/#{enc(id)}", body: body)
268
+ end
269
+
270
+ def delete(id)
271
+ @http.request("DELETE", "/campaigns/#{enc(id)}")
272
+ end
273
+
274
+ def send(id)
275
+ @http.request("POST", "/campaigns/#{enc(id)}/send")
276
+ end
277
+
278
+ def cancel(id)
279
+ @http.request("POST", "/campaigns/#{enc(id)}/cancel")
280
+ end
281
+
282
+ def pause(id)
283
+ @http.request("POST", "/campaigns/#{enc(id)}/pause")
284
+ end
285
+
286
+ def resume(id)
287
+ @http.request("POST", "/campaigns/#{enc(id)}/resume")
288
+ end
289
+
290
+ def reschedule(id, body)
291
+ @http.request("POST", "/campaigns/#{enc(id)}/reschedule", body: body)
292
+ end
293
+
294
+ def validate(id)
295
+ @http.request("GET", "/campaigns/#{enc(id)}/validate")
296
+ end
297
+ end
298
+
299
+ class Templates < Base
300
+ def list(limit: nil, after: nil, before: nil)
301
+ @http.request("GET", "/templates", query: list_params(limit: limit, after: after, before: before))
302
+ end
303
+
304
+ def list_all(**params)
305
+ paginate("/templates", params)
306
+ end
307
+
308
+ def create(body)
309
+ @http.request("POST", "/templates", body: body)
310
+ end
311
+
312
+ def get(id)
313
+ @http.request("GET", "/templates/#{enc(id)}")
314
+ end
315
+
316
+ def update(id, body)
317
+ @http.request("PATCH", "/templates/#{enc(id)}", body: body)
318
+ end
319
+
320
+ def delete(id)
321
+ @http.request("DELETE", "/templates/#{enc(id)}")
322
+ end
323
+ end
324
+
325
+ # Read-only Brand Kit profiles (voice, colors, pinned /img/{id} graphics).
326
+ class BrandKits < Base
327
+ def list(name: nil)
328
+ query = {}
329
+ query[:name] = name unless name.nil?
330
+ @http.request("GET", "/brand-kits", query: query.empty? ? nil : query)
331
+ end
332
+
333
+ def get(id)
334
+ @http.request("GET", "/brand-kits/#{enc(id)}")
335
+ end
336
+ end
337
+
338
+ class Domains < Base
339
+ def list(limit: nil, after: nil, before: nil)
340
+ @http.request("GET", "/domains", query: list_params(limit: limit, after: after, before: before))
341
+ end
342
+
343
+ def list_all(**params)
344
+ paginate("/domains", params)
345
+ end
346
+
347
+ def create(body)
348
+ @http.request("POST", "/domains", body: body)
349
+ end
350
+
351
+ def get(id)
352
+ @http.request("GET", "/domains/#{enc(id)}")
353
+ end
354
+
355
+ def delete(id)
356
+ @http.request("DELETE", "/domains/#{enc(id)}")
357
+ end
358
+
359
+ def update(id, body)
360
+ @http.request("PATCH", "/domains/#{enc(id)}", body: body)
361
+ end
362
+
363
+ def verify(id)
364
+ @http.request("POST", "/domains/#{enc(id)}/verify")
365
+ end
366
+ end
367
+
368
+ class Webhooks < Base
369
+ def list(limit: nil, after: nil, before: nil)
370
+ @http.request("GET", "/webhooks", query: list_params(limit: limit, after: after, before: before))
371
+ end
372
+
373
+ def list_all(**params)
374
+ paginate("/webhooks", params)
375
+ end
376
+
377
+ def create(body)
378
+ @http.request("POST", "/webhooks", body: body)
379
+ end
380
+
381
+ def get(id)
382
+ @http.request("GET", "/webhooks/#{enc(id)}")
383
+ end
384
+
385
+ def update(id, body)
386
+ @http.request("PATCH", "/webhooks/#{enc(id)}", body: body)
387
+ end
388
+
389
+ def delete(id)
390
+ @http.request("DELETE", "/webhooks/#{enc(id)}")
391
+ end
392
+
393
+ def test(id)
394
+ @http.request("POST", "/webhooks/#{enc(id)}/test")
395
+ end
396
+
397
+ # Verify a delivery's signature and return the parsed event. Alias of
398
+ # Unitpost.verify_webhook / Unitpost::Webhooks.verify for a Resend-style
399
+ # client.webhooks.verify(...). Raises Unitpost::WebhookVerificationError on
400
+ # a missing/invalid/stale signature.
401
+ def verify(payload:, secret:, headers:, tolerance_seconds: 300)
402
+ Unitpost::Webhooks.verify(
403
+ payload: payload,
404
+ secret: secret,
405
+ headers: headers,
406
+ tolerance_seconds: tolerance_seconds,
407
+ )
408
+ end
409
+ end
410
+
411
+ class ApiKeys < Base
412
+ def list(limit: nil, after: nil, before: nil)
413
+ @http.request("GET", "/api-keys", query: list_params(limit: limit, after: after, before: before))
414
+ end
415
+
416
+ def list_all(**params)
417
+ paginate("/api-keys", params)
418
+ end
419
+
420
+ def create(body)
421
+ @http.request("POST", "/api-keys", body: body)
422
+ end
423
+
424
+ def delete(id)
425
+ @http.request("DELETE", "/api-keys/#{enc(id)}")
426
+ end
427
+ end
428
+
429
+ class Suppressions < Base
430
+ def list(limit: nil, after: nil, before: nil, **filters)
431
+ @http.request("GET", "/suppressions", query: list_params(limit: limit, after: after, before: before, **filters))
432
+ end
433
+
434
+ def list_all(**params)
435
+ paginate("/suppressions", params)
436
+ end
437
+
438
+ # Single (`{ email: ... }`) or bulk (`{ emails: [...] }`). Idempotent.
439
+ def create(body)
440
+ @http.request("POST", "/suppressions", body: body)
441
+ end
442
+
443
+ def get(id_or_email)
444
+ @http.request("GET", "/suppressions/#{enc(id_or_email)}")
445
+ end
446
+
447
+ def delete(id_or_email)
448
+ @http.request("DELETE", "/suppressions/#{enc(id_or_email)}")
449
+ end
450
+ end
451
+
452
+ # NOTE (pre-launch): the Events resource (POST /v1/events) is intentionally
453
+ # absent while the automations/custom-events surface is behind the launch
454
+ # gate. Restore it (class Events + client wiring) when the surface ships.
455
+
456
+ class Usage < Base
457
+ # Current billing-period snapshot: plan, period window, emails sent, and
458
+ # (paid plans) the dollar sending wallet in integer USD cents.
459
+ def get
460
+ @http.request("GET", "/usage")
461
+ end
462
+ end
463
+ end
464
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Unitpost
2
- VERSION = "0.0.0"
4
+ VERSION = "0.1.0"
3
5
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "json"
6
+ require_relative "errors"
7
+
8
+ module Unitpost
9
+ # Webhook signature verification (svix-compatible). Mirrors the engine signer:
10
+ # signed content: "<svix-id>.<svix-timestamp>.<raw-body>"
11
+ # signature: base64( HMAC_SHA256(secret_bytes, signed_content) )
12
+ # header: svix-signature = "v1,<base64>" (space-separated list allowed)
13
+ # secret: "whsec_<base64url>" — bytes after the prefix are the key.
14
+ #
15
+ # Verify the RAW request body string (not a re-serialized hash).
16
+ module Webhooks
17
+ SECRET_PREFIX = "whsec_"
18
+
19
+ module_function
20
+
21
+ def verify(payload:, secret:, headers:, tolerance_seconds: 300)
22
+ msg_id = header(headers, "svix-id")
23
+ timestamp = header(headers, "svix-timestamp")
24
+ signature = header(headers, "svix-signature")
25
+
26
+ if msg_id.nil? || timestamp.nil? || signature.nil?
27
+ raise WebhookVerificationError, "Missing svix-id, svix-timestamp, or svix-signature header."
28
+ end
29
+
30
+ ts = Integer(timestamp, exception: false)
31
+ raise WebhookVerificationError, "Invalid svix-timestamp header." if ts.nil?
32
+
33
+ now = Time.now.to_i
34
+ if (now - ts).abs > tolerance_seconds
35
+ raise WebhookVerificationError, "Webhook timestamp is outside the tolerance window (possible replay)."
36
+ end
37
+
38
+ signed_content = "#{msg_id}.#{ts}.#{payload}"
39
+ expected = Base64.strict_encode64(
40
+ OpenSSL::HMAC.digest("SHA256", secret_key_bytes(secret), signed_content)
41
+ )
42
+
43
+ matched = signature.split(" ").any? do |part|
44
+ _, sig = part.split(",", 2)
45
+ sig && secure_compare(sig, expected)
46
+ end
47
+ raise WebhookVerificationError, "Webhook signature verification failed." unless matched
48
+
49
+ begin
50
+ JSON.parse(payload)
51
+ rescue JSON::ParserError
52
+ raise WebhookVerificationError, "Webhook payload is not valid JSON."
53
+ end
54
+ end
55
+
56
+ def header(headers, name)
57
+ headers[name] || headers[name.downcase] || headers[name.split("-").map(&:capitalize).join("-")]
58
+ end
59
+
60
+ def secret_key_bytes(secret)
61
+ raw = secret.start_with?(SECRET_PREFIX) ? secret[SECRET_PREFIX.length..] : secret
62
+ Base64.urlsafe_decode64(raw + ("=" * ((4 - (raw.length % 4)) % 4)))
63
+ end
64
+
65
+ def secure_compare(a, b)
66
+ return false unless a.bytesize == b.bytesize
67
+
68
+ OpenSSL.fixed_length_secure_compare(a, b)
69
+ rescue StandardError
70
+ # Fallback for older OpenSSL without fixed_length_secure_compare.
71
+ res = 0
72
+ a.bytes.zip(b.bytes) { |x, y| res |= x ^ y }
73
+ res.zero?
74
+ end
75
+ end
76
+ end