hooksniff 1.1.1 → 1.3.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +45 -20
  4. data/hooksniff.gemspec +1 -1
  5. data/lib/hooksniff/api/alert.rb +34 -0
  6. data/lib/hooksniff/api/analytics.rb +21 -0
  7. data/lib/hooksniff/api/api_key.rb +26 -0
  8. data/lib/hooksniff/api/application.rb +30 -0
  9. data/lib/hooksniff/api/audit_log.rb +17 -0
  10. data/lib/hooksniff/api/authentication.rb +0 -13
  11. data/lib/hooksniff/api/background_task.rb +21 -0
  12. data/lib/hooksniff/api/billing.rb +34 -0
  13. data/lib/hooksniff/api/connector.rb +33 -0
  14. data/lib/hooksniff/api/custom_domain.rb +26 -0
  15. data/lib/hooksniff/api/environment.rb +47 -0
  16. data/lib/hooksniff/api/inbound.rb +30 -0
  17. data/lib/hooksniff/api/integration.rb +8 -8
  18. data/lib/hooksniff/api/message_poller.rb +21 -0
  19. data/lib/hooksniff/api/notification.rb +30 -0
  20. data/lib/hooksniff/api/operational_webhook.rb +34 -0
  21. data/lib/hooksniff/api/playground.rb +17 -0
  22. data/lib/hooksniff/api/portal.rb +33 -0
  23. data/lib/hooksniff/api/rate_limit.rb +26 -0
  24. data/lib/hooksniff/api/routing.rb +21 -0
  25. data/lib/hooksniff/api/schema.rb +25 -0
  26. data/lib/hooksniff/api/search.rb +13 -0
  27. data/lib/hooksniff/api/service_token.rb +22 -0
  28. data/lib/hooksniff/api/sso.rb +26 -0
  29. data/lib/hooksniff/api/statistics.rb +0 -13
  30. data/lib/hooksniff/api/stream.rb +28 -9
  31. data/lib/hooksniff/api/team.rb +42 -0
  32. data/lib/hooksniff/api/template.rb +21 -0
  33. data/lib/hooksniff/errors.rb +79 -0
  34. data/lib/hooksniff/hooksniff_http_client.rb +48 -9
  35. data/lib/hooksniff/models/aggregate_event_types_out.rb +59 -0
  36. data/lib/hooksniff/models/message_attempt_recovered_event.rb +53 -0
  37. data/lib/hooksniff/models/message_attempt_recovered_event_data.rb +70 -0
  38. data/lib/hooksniff/models/message_in.rb +1 -0
  39. data/lib/hooksniff/options.rb +31 -0
  40. data/lib/hooksniff/paginator.rb +45 -0
  41. data/lib/hooksniff/response_metadata.rb +48 -0
  42. data/lib/hooksniff/version.rb +1 -1
  43. data/lib/hooksniff/webhook.rb +62 -0
  44. data/lib/hooksniff/webhook_event.rb +295 -0
  45. data/lib/hooksniff.rb +135 -66
  46. data/test/test_hooksniff.rb +0 -11
  47. data/test/test_typed_events.rb +260 -0
  48. metadata +36 -13
  49. data/lib/hooksniff/background_task.rb +0 -21
  50. data/lib/hooksniff/connector.rb +0 -33
  51. data/lib/hooksniff/environment.rb +0 -53
  52. data/lib/hooksniff/inbound.rb +0 -25
  53. data/lib/hooksniff/message_poller.rb +0 -32
  54. data/lib/hooksniff/operational_webhook.rb +0 -12
@@ -63,6 +63,7 @@ module HookSniff
63
63
  def self.deserialize(attributes = {})
64
64
  attributes = attributes.transform_keys(&:to_s)
65
65
  attrs = Hash.new
66
+ attrs["application"] = HookSniff::ApplicationIn.deserialize(attributes["application"]) if attributes["application"]
66
67
  attrs["channels"] = attributes["channels"]
67
68
  attrs["deliver_at"] = DateTime.rfc3339(attributes["deliverAt"]).to_time if attributes["deliverAt"]
68
69
  attrs["event_id"] = attributes["eventId"]
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HookSniff
4
+ # Configuration options for the HookSniff client.
5
+ #
6
+ # @example
7
+ # options = HookSniff::Options.new(
8
+ # server_url: "https://custom.hooksniff.com",
9
+ # timeout: 60,
10
+ # debug: true,
11
+ # headers: { "X-Custom" => "value" }
12
+ # )
13
+ # client = HookSniff::HookSniff.new("token", options)
14
+ class Options
15
+ attr_accessor :server_url, :timeout, :debug, :headers, :retry_schedule
16
+
17
+ def initialize(
18
+ server_url: nil,
19
+ timeout: 30,
20
+ debug: false,
21
+ headers: {},
22
+ retry_schedule: [1, 2, 4]
23
+ )
24
+ @server_url = server_url
25
+ @timeout = timeout
26
+ @debug = debug
27
+ @headers = headers || {}
28
+ @retry_schedule = retry_schedule
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,45 @@
1
+ # Pagination Helper for HookSniff Ruby SDK.
2
+ #
3
+ # Usage:
4
+ # hs.message.list_all(limit: 100).each do |msg|
5
+ # puts msg.id
6
+ # end
7
+ #
8
+ # # Or collect all
9
+ # all_messages = hs.message.list_all(limit: 100).to_a
10
+
11
+ module HookSniff
12
+ class Paginator
13
+ include Enumerable
14
+
15
+ def initialize(fetch_page, limit: nil)
16
+ @fetch_page = fetch_page
17
+ @limit = limit
18
+ end
19
+
20
+ def each(&block)
21
+ return enum_for(:each) unless block_given?
22
+
23
+ iterator = nil
24
+
25
+ loop do
26
+ page = @fetch_page.call(limit: @limit, iterator: iterator)
27
+
28
+ page.data.each(&block)
29
+
30
+ break if page.done || page.iterator.nil? || page.iterator.empty?
31
+ iterator = page.iterator
32
+ end
33
+ end
34
+
35
+ # Collect all items into an array
36
+ def to_a
37
+ each.to_a
38
+ end
39
+
40
+ # Count all items
41
+ def count
42
+ each.count
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HookSniff
4
+ # Response metadata from the last API request.
5
+ #
6
+ # Access via +client.last_response+ after any API call.
7
+ #
8
+ # @example
9
+ # endpoints = client.endpoint.list
10
+ # puts client.last_response.request_id
11
+ # puts client.last_response.rate_limit_remaining
12
+ class ResponseMetadata
13
+ # @return [Integer] HTTP status code
14
+ attr_reader :status_code
15
+
16
+ # @return [String, nil] x-request-id header
17
+ attr_reader :request_id
18
+
19
+ # @return [Integer, nil] x-ratelimit-remaining header
20
+ attr_reader :rate_limit_remaining
21
+
22
+ # @return [Integer, nil] x-ratelimit-reset header (Unix timestamp)
23
+ attr_reader :rate_limit_reset
24
+
25
+ # @return [Hash] All response headers
26
+ attr_reader :headers
27
+
28
+ def initialize(status_code:, request_id: nil, rate_limit_remaining: nil, rate_limit_reset: nil, headers: {})
29
+ @status_code = status_code
30
+ @request_id = request_id
31
+ @rate_limit_remaining = rate_limit_remaining
32
+ @rate_limit_reset = rate_limit_reset
33
+ @headers = headers
34
+ end
35
+
36
+ # Create from a Net::HTTP response.
37
+ def self.from_http_response(response)
38
+ headers = response.to_hash.transform_values { |v| v.is_a?(Array) ? v.first : v }
39
+ new(
40
+ status_code: response.code.to_i,
41
+ request_id: headers["x-request-id"],
42
+ rate_limit_remaining: headers["x-ratelimit-remaining"]&.to_i,
43
+ rate_limit_reset: headers["x-ratelimit-reset"]&.to_i,
44
+ headers: headers
45
+ )
46
+ end
47
+ end
48
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HookSniff
4
- VERSION = "1.1.1"
4
+ VERSION = "1.3.0"
5
5
  end
@@ -1,6 +1,13 @@
1
1
  # frozen_string_literal: true
2
+ require "base64"
3
+ require "json"
2
4
 
3
5
  module HookSniff
6
+ # Error raised when webhook signature verification fails
7
+ class WebhookVerificationError < StandardError; end
8
+ # Error raised when webhook signing fails
9
+ class WebhookSigningError < StandardError; end
10
+
4
11
  class Webhook
5
12
 
6
13
  def self.new_using_raw_bytes(secret)
@@ -15,6 +22,15 @@ module HookSniff
15
22
  @secret = Base64.decode64(secret)
16
23
  end
17
24
 
25
+ # Verify and parse a webhook payload.
26
+ #
27
+ # Verifies the HMAC-SHA256 signature, then parses the payload
28
+ # into a typed WebhookEvent with +event+, +data+, and +timestamp+.
29
+ #
30
+ # @param payload [String] raw request body
31
+ # @param headers [Hash] request headers containing hooksniff-id, hooksniff-timestamp, hooksniff-signature
32
+ # @return [WebhookEvent] parsed webhook event
33
+ # @raise [WebhookVerificationError] if signature is invalid or timestamp is outside tolerance
18
34
  def verify(payload, headers)
19
35
  msgId = headers["hooksniff-id"]
20
36
  msgSignature = headers["hooksniff-signature"]
@@ -32,6 +48,45 @@ module HookSniff
32
48
 
33
49
  _, signature = sign(msgId, msgTimestamp, payload).split(",", 2)
34
50
 
51
+ passedSignatures = msgSignature.split(" ")
52
+ passedSignatures.each do |versionedSignature|
53
+ version, expectedSignature = versionedSignature.split(",", 2)
54
+ if version != "v1"
55
+ next
56
+ end
57
+
58
+ if ::HookSniff::secure_compare(signature, expectedSignature)
59
+ return parse_payload(payload)
60
+ end
61
+ end
62
+
63
+ raise WebhookVerificationError, "No matching signature found"
64
+ end
65
+
66
+ # Verify and return raw payload without parsing.
67
+ # Use this when you need the raw hash instead of a typed event.
68
+ #
69
+ # @param payload [String] raw request body
70
+ # @param headers [Hash] request headers
71
+ # @return [Hash, nil] parsed JSON hash
72
+ # @raise [WebhookVerificationError] if signature is invalid
73
+ def verify_raw(payload, headers)
74
+ msgId = headers["hooksniff-id"]
75
+ msgSignature = headers["hooksniff-signature"]
76
+ msgTimestamp = headers["hooksniff-timestamp"]
77
+ if !msgSignature || !msgId || !msgTimestamp
78
+ msgId = headers["webhook-id"]
79
+ msgSignature = headers["webhook-signature"]
80
+ msgTimestamp = headers["webhook-timestamp"]
81
+ if !msgSignature || !msgId || !msgTimestamp
82
+ raise WebhookVerificationError, "Missing required headers"
83
+ end
84
+ end
85
+
86
+ verify_timestamp(msgTimestamp)
87
+
88
+ _, signature = sign(msgId, msgTimestamp, payload).split(",", 2)
89
+
35
90
  passedSignatures = msgSignature.split(" ")
36
91
  passedSignatures.each do |versionedSignature|
37
92
  version, expectedSignature = versionedSignature.split(",", 2)
@@ -80,5 +135,12 @@ module HookSniff
80
135
  raise WebhookVerificationError, "Message timestamp too new"
81
136
  end
82
137
  end
138
+
139
+ def parse_payload(payload)
140
+ return WebhookEvent.new(event: "", data: {}, timestamp: "") if payload.empty?
141
+
142
+ parsed = JSON.parse(payload, symbolize_names: true)
143
+ WebhookEvent.parse(parsed)
144
+ end
83
145
  end
84
146
  end
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HookSniff
4
+ # ─── Event Data Classes ─────────────────────────────────────────
5
+
6
+ # Data payload for endpoint.created events.
7
+ class EndpointCreatedEventData
8
+ attr_reader :app_id, :endpoint_id, :app_uid
9
+
10
+ def initialize(app_id:, endpoint_id:, app_uid: nil)
11
+ @app_id = app_id
12
+ @endpoint_id = endpoint_id
13
+ @app_uid = app_uid
14
+ end
15
+ end
16
+
17
+ # Data payload for endpoint.updated events.
18
+ class EndpointUpdatedEventData
19
+ attr_reader :app_id, :endpoint_id, :app_uid
20
+
21
+ def initialize(app_id:, endpoint_id:, app_uid: nil)
22
+ @app_id = app_id
23
+ @endpoint_id = endpoint_id
24
+ @app_uid = app_uid
25
+ end
26
+ end
27
+
28
+ # Data payload for endpoint.deleted events.
29
+ class EndpointDeletedEventData
30
+ attr_reader :app_id, :endpoint_id, :app_uid
31
+
32
+ def initialize(app_id:, endpoint_id:, app_uid: nil)
33
+ @app_id = app_id
34
+ @endpoint_id = endpoint_id
35
+ @app_uid = app_uid
36
+ end
37
+ end
38
+
39
+ # Data payload for endpoint.enabled events.
40
+ class EndpointEnabledEventData
41
+ attr_reader :app_id, :endpoint_id, :app_uid
42
+
43
+ def initialize(app_id:, endpoint_id:, app_uid: nil)
44
+ @app_id = app_id
45
+ @endpoint_id = endpoint_id
46
+ @app_uid = app_uid
47
+ end
48
+ end
49
+
50
+ # Data payload for endpoint.disabled events.
51
+ class EndpointDisabledEventData
52
+ attr_reader :app_id, :endpoint_id, :app_uid, :fail_since, :trigger
53
+
54
+ def initialize(app_id:, endpoint_id:, app_uid: nil, fail_since: nil, trigger: nil)
55
+ @app_id = app_id
56
+ @endpoint_id = endpoint_id
57
+ @app_uid = app_uid
58
+ @fail_since = fail_since
59
+ @trigger = trigger
60
+ end
61
+ end
62
+
63
+ # Info about the last delivery attempt.
64
+ class LastAttemptInfo
65
+ attr_reader :id, :timestamp, :response_status_code
66
+
67
+ def initialize(id:, timestamp:, response_status_code:)
68
+ @id = id
69
+ @timestamp = timestamp
70
+ @response_status_code = response_status_code
71
+ end
72
+ end
73
+
74
+ # Info about a delivery attempt.
75
+ class AttemptInfo
76
+ attr_reader :id, :timestamp, :response_status_code
77
+
78
+ def initialize(id:, timestamp:, response_status_code:)
79
+ @id = id
80
+ @timestamp = timestamp
81
+ @response_status_code = response_status_code
82
+ end
83
+ end
84
+
85
+ # Data payload for message.attempt.exhausted events.
86
+ class MessageAttemptExhaustedEventData
87
+ attr_reader :app_id, :msg_id, :last_attempt, :app_uid
88
+
89
+ def initialize(app_id:, msg_id:, last_attempt:, app_uid: nil)
90
+ @app_id = app_id
91
+ @msg_id = msg_id
92
+ @last_attempt = last_attempt
93
+ @app_uid = app_uid
94
+ end
95
+ end
96
+
97
+ # Data payload for message.attempt.failing events.
98
+ class MessageAttemptFailingEventData
99
+ attr_reader :app_id, :msg_id, :attempt, :app_uid
100
+
101
+ def initialize(app_id:, msg_id:, attempt:, app_uid: nil)
102
+ @app_id = app_id
103
+ @msg_id = msg_id
104
+ @attempt = attempt
105
+ @app_uid = app_uid
106
+ end
107
+ end
108
+
109
+ # Data payload for message.attempt.recovered events.
110
+ class MessageAttemptRecoveredEventData
111
+ attr_reader :app_id, :msg_id, :attempt, :app_uid
112
+
113
+ def initialize(app_id:, msg_id:, attempt:, app_uid: nil)
114
+ @app_id = app_id
115
+ @msg_id = msg_id
116
+ @attempt = attempt
117
+ @app_uid = app_uid
118
+ end
119
+ end
120
+
121
+ # ─── WebhookEvent (base class — must come before subclasses) ────
122
+
123
+ # Represents a parsed webhook event from HookSniff.
124
+ class WebhookEvent
125
+ # @return [String] event type name (e.g., "endpoint.created")
126
+ attr_reader :event
127
+
128
+ # @return [Object] event payload data (typed data class or Hash)
129
+ attr_reader :data
130
+
131
+ # @return [String] ISO 8601 timestamp string
132
+ attr_reader :timestamp
133
+
134
+ def initialize(event:, data:, timestamp:)
135
+ @event = event
136
+ @data = data
137
+ @timestamp = timestamp
138
+ end
139
+
140
+ # Alias for +event+ — the event type name.
141
+ def event_type
142
+ @event
143
+ end
144
+
145
+ # Get a value from the data (Hash or typed object).
146
+ def get(key)
147
+ if @data.is_a?(Hash)
148
+ @data[key.to_s] || @data[key.to_sym]
149
+ else
150
+ @data.respond_to?(key.to_sym) ? @data.send(key.to_sym) : nil
151
+ end
152
+ end
153
+
154
+ # Access data values with bracket notation (backward compat).
155
+ def [](key)
156
+ get(key)
157
+ end
158
+
159
+ # Check if key exists in data.
160
+ def key?(key)
161
+ if @data.is_a?(Hash)
162
+ @data.key?(key.to_s) || @data.key?(key.to_sym)
163
+ else
164
+ @data.respond_to?(key.to_sym)
165
+ end
166
+ end
167
+
168
+ def to_s
169
+ "#<#{self.class.name} event=#{@event} timestamp=#{@timestamp}>"
170
+ end
171
+
172
+ def inspect
173
+ to_s
174
+ end
175
+
176
+ # Map of known event types to their classes
177
+ EVENT_TYPE_MAP = {
178
+ "endpoint.created" => "EndpointCreatedEvent",
179
+ "endpoint.updated" => "EndpointUpdatedEvent",
180
+ "endpoint.deleted" => "EndpointDeletedEvent",
181
+ "endpoint.enabled" => "EndpointEnabledEvent",
182
+ "endpoint.disabled" => "EndpointDisabledEvent",
183
+ "message.attempt.exhausted" => "MessageAttemptExhaustedEvent",
184
+ "message.attempt.failing" => "MessageAttemptFailingEvent",
185
+ "message.atattempt.failing" => "MessageAttemptFailingEvent",
186
+ "message.attempt.recovered" => "MessageAttemptRecoveredEvent",
187
+ "message.atattempt.recovered" => "MessageAttemptRecoveredEvent",
188
+ }.freeze
189
+
190
+ # Parse a webhook payload hash into a typed WebhookEvent.
191
+ def self.parse(data)
192
+ event_type = data[:event] || data["event"] || data[:eventType] || data["eventType"] || ""
193
+ raw_data = data[:data] || data["data"] || {}
194
+ timestamp = data[:timestamp] || data["timestamp"] || ""
195
+
196
+ parsed_data = parse_event_data(event_type, raw_data)
197
+ class_name = EVENT_TYPE_MAP[event_type]
198
+ event_class = class_name ? const_get(class_name) : WebhookEvent
199
+
200
+ event_class.new(event: event_type, data: parsed_data, timestamp: timestamp)
201
+ end
202
+
203
+ private
204
+
205
+ def self.parse_event_data(event_type, raw)
206
+ case event_type
207
+ when "endpoint.created"
208
+ EndpointCreatedEventData.new(
209
+ app_id: raw[:appId] || raw["appId"] || raw[:app_id] || raw["app_id"] || "",
210
+ endpoint_id: raw[:endpointId] || raw["endpointId"] || raw[:endpoint_id] || raw["endpoint_id"] || "",
211
+ app_uid: raw[:appUid] || raw["appUid"] || raw[:app_uid] || raw["app_uid"]
212
+ )
213
+ when "endpoint.updated"
214
+ EndpointUpdatedEventData.new(
215
+ app_id: raw[:appId] || raw["appId"] || raw[:app_id] || raw["app_id"] || "",
216
+ endpoint_id: raw[:endpointId] || raw["endpointId"] || raw[:endpoint_id] || raw["endpoint_id"] || "",
217
+ app_uid: raw[:appUid] || raw["appUid"] || raw[:app_uid] || raw["app_uid"]
218
+ )
219
+ when "endpoint.deleted"
220
+ EndpointDeletedEventData.new(
221
+ app_id: raw[:appId] || raw["appId"] || raw[:app_id] || raw["app_id"] || "",
222
+ endpoint_id: raw[:endpointId] || raw["endpointId"] || raw[:endpoint_id] || raw["endpoint_id"] || "",
223
+ app_uid: raw[:appUid] || raw["appUid"] || raw[:app_uid] || raw["app_uid"]
224
+ )
225
+ when "endpoint.enabled"
226
+ EndpointEnabledEventData.new(
227
+ app_id: raw[:appId] || raw["appId"] || raw[:app_id] || raw["app_id"] || "",
228
+ endpoint_id: raw[:endpointId] || raw["endpointId"] || raw[:endpoint_id] || raw["endpoint_id"] || "",
229
+ app_uid: raw[:appUid] || raw["appUid"] || raw[:app_uid] || raw["app_uid"]
230
+ )
231
+ when "endpoint.disabled"
232
+ EndpointDisabledEventData.new(
233
+ app_id: raw[:appId] || raw["appId"] || raw[:app_id] || raw["app_id"] || "",
234
+ endpoint_id: raw[:endpointId] || raw["endpointId"] || raw[:endpoint_id] || raw["endpoint_id"] || "",
235
+ app_uid: raw[:appUid] || raw["appUid"] || raw[:app_uid] || raw["app_uid"],
236
+ fail_since: raw[:failSince] || raw["failSince"] || raw[:fail_since] || raw["fail_since"],
237
+ trigger: raw[:trigger] || raw["trigger"]
238
+ )
239
+ when "message.attempt.exhausted"
240
+ last_raw = raw[:lastAttempt] || raw["lastAttempt"] || raw[:last_attempt] || raw["last_attempt"] || {}
241
+ MessageAttemptExhaustedEventData.new(
242
+ app_id: raw[:appId] || raw["appId"] || raw[:app_id] || raw["app_id"] || "",
243
+ msg_id: raw[:msgId] || raw["msgId"] || raw[:msg_id] || raw["msg_id"] || "",
244
+ last_attempt: parse_last_attempt(last_raw),
245
+ app_uid: raw[:appUid] || raw["appUid"] || raw[:app_uid] || raw["app_uid"]
246
+ )
247
+ when "message.attempt.failing", "message.atattempt.failing"
248
+ attempt_raw = raw[:attempt] || raw["attempt"] || {}
249
+ MessageAttemptFailingEventData.new(
250
+ app_id: raw[:appId] || raw["appId"] || raw[:app_id] || raw["app_id"] || "",
251
+ msg_id: raw[:msgId] || raw["msgId"] || raw[:msg_id] || raw["msg_id"] || "",
252
+ attempt: parse_attempt(attempt_raw),
253
+ app_uid: raw[:appUid] || raw["appUid"] || raw[:app_uid] || raw["app_uid"]
254
+ )
255
+ when "message.atattempt.recovered", "message.attempt.recovered"
256
+ attempt_raw = raw[:attempt] || raw["attempt"] || {}
257
+ MessageAttemptRecoveredEventData.new(
258
+ app_id: raw[:appId] || raw["appId"] || raw[:app_id] || raw["app_id"] || "",
259
+ msg_id: raw[:msgId] || raw["msgId"] || raw[:msg_id] || raw["msg_id"] || "",
260
+ attempt: parse_attempt(attempt_raw),
261
+ app_uid: raw[:appUid] || raw["appUid"] || raw[:app_uid] || raw["app_uid"]
262
+ )
263
+ else
264
+ raw
265
+ end
266
+ end
267
+
268
+ def self.parse_last_attempt(raw)
269
+ LastAttemptInfo.new(
270
+ id: raw[:id] || raw["id"] || "",
271
+ timestamp: raw[:timestamp] || raw["timestamp"] || "",
272
+ response_status_code: raw[:responseStatusCode] || raw["responseStatusCode"] || raw[:response_status_code] || raw["response_status_code"] || 0
273
+ )
274
+ end
275
+
276
+ def self.parse_attempt(raw)
277
+ AttemptInfo.new(
278
+ id: raw[:id] || raw["id"] || "",
279
+ timestamp: raw[:timestamp] || raw["timestamp"] || "",
280
+ response_status_code: raw[:responseStatusCode] || raw["responseStatusCode"] || raw[:response_status_code] || raw["response_status_code"] || 0
281
+ )
282
+ end
283
+ end
284
+
285
+ # ─── Typed Event Subclasses ─────────────────────────────────────
286
+
287
+ class EndpointCreatedEvent < WebhookEvent; end
288
+ class EndpointUpdatedEvent < WebhookEvent; end
289
+ class EndpointDeletedEvent < WebhookEvent; end
290
+ class EndpointEnabledEvent < WebhookEvent; end
291
+ class EndpointDisabledEvent < WebhookEvent; end
292
+ class MessageAttemptExhaustedEvent < WebhookEvent; end
293
+ class MessageAttemptFailingEvent < WebhookEvent; end
294
+ class MessageAttemptRecoveredEvent < WebhookEvent; end
295
+ end