hooksniff 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d57c84b0d53e5a0affb8331d0a7b6f875783237e8e39791c472be7dde9b55633
4
+ data.tar.gz: c0515dc716d6c05b45779530f87af8c0bfa1be1e7de760055510311ebbdf60c8
5
+ SHA512:
6
+ metadata.gz: 68b178b1d814e4112af255c973522bc461d21042ab835c90dbc4ad7d4a7be29c1fd5c6d9ff0c7416cd79776e90422dc30e525def3f125c1cf8a5662b9eea1f01
7
+ data.tar.gz: f1c27cbb1954ab4a1785cdd351bce37d1bdce6b695282925c07a2c90f19ffbf4927ca2e702f089666679f0ceafaef7844777d4a97322101b8818f363ee084229
data/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # HookSniff Ruby SDK
2
+
3
+ Official Ruby client for the [HookSniff](https://hooksniff.vercel.app) webhook delivery service.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "hooksniff"
11
+ ```
12
+
13
+ Or install directly:
14
+
15
+ ```bash
16
+ gem install hooksniff
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ```ruby
22
+ require "hooksniff"
23
+
24
+ # Initialize client
25
+ client = HookSniff::Client.new(api_key: "hr_live_your_api_key_here")
26
+
27
+ # Create a webhook endpoint
28
+ endpoint = client.endpoints.create(
29
+ url: "https://myapp.com/webhook",
30
+ description: "Order notifications"
31
+ )
32
+ puts "Endpoint created: #{endpoint[:id]}"
33
+
34
+ # Send a webhook
35
+ delivery = client.webhooks.send(
36
+ endpoint_id: endpoint[:id],
37
+ event: "order.created",
38
+ data: { order_id: "12345", amount: 99.99 }
39
+ )
40
+ puts "Delivery queued: #{delivery[:id]}, status: #{delivery[:status]}"
41
+
42
+ # Check delivery status
43
+ status = client.webhooks.get(delivery[:id])
44
+ puts "Status: #{status[:status]}, attempts: #{status[:attempt_count]}"
45
+
46
+ # List deliveries
47
+ deliveries = client.webhooks.list(status: "failed", page: 1)
48
+ deliveries[:deliveries].each do |d|
49
+ puts " #{d[:id]}: #{d[:status]}"
50
+ end
51
+
52
+ # Replay a failed delivery
53
+ replayed = client.webhooks.replay(delivery[:id])
54
+ puts "Replay queued: #{replayed[:id]}"
55
+ ```
56
+
57
+ ## Batch Webhooks
58
+
59
+ Send multiple webhooks in a single request (max 100):
60
+
61
+ ```ruby
62
+ results = client.webhooks.batch([
63
+ { endpoint_id: "ep_1", event: "order.created", data: { order_id: "12345" } },
64
+ { endpoint_id: "ep_2", event: "payment.completed", data: { payment_id: "pay_67890" } }
65
+ ])
66
+
67
+ puts "Delivered: #{results[:deliveries].length}"
68
+ puts "Errors: #{results[:errors].length}"
69
+ results[:errors].each do |err|
70
+ puts " Item #{err['index']}: #{err['error']}"
71
+ end
72
+ ```
73
+
74
+ ## Retry Policy
75
+
76
+ Configure custom retry behavior when creating endpoints:
77
+
78
+ ```ruby
79
+ endpoint = client.endpoints.create(
80
+ url: "https://myapp.com/webhook",
81
+ description: "Critical notifications",
82
+ retry_policy: {
83
+ max_attempts: 5,
84
+ backoff: "exponential",
85
+ initial_delay_secs: 10,
86
+ max_delay_secs: 3600
87
+ }
88
+ )
89
+ ```
90
+
91
+ ## Delivery Attempts
92
+
93
+ Inspect individual delivery attempts:
94
+
95
+ ```ruby
96
+ attempts = client.webhooks.attempts(delivery[:id])
97
+ attempts.each do |attempt|
98
+ puts " Attempt #{attempt[:attempt_number]}: status=#{attempt[:status_code]}, " \
99
+ "duration=#{attempt[:duration_ms]}ms"
100
+ puts " Error: #{attempt[:error_message]}" if attempt[:error_message]
101
+ end
102
+ ```
103
+
104
+ ## Export Logs
105
+
106
+ Export webhook logs as JSON or CSV:
107
+
108
+ ```ruby
109
+ # JSON export
110
+ logs = client.webhooks.export(format: "json", status: "failed")
111
+
112
+ # CSV export
113
+ csv_data = client.webhooks.export(format: "csv", date_from: "2024-01-01")
114
+ File.write("webhooks.csv", csv_data)
115
+ ```
116
+
117
+ ## Signature Verification
118
+
119
+ Verify incoming webhook signatures in your handler:
120
+
121
+ ```ruby
122
+ require "hooksniff"
123
+ require "sinatra"
124
+
125
+ post "/webhook" do
126
+ payload = request.body.read
127
+ signature = request.env["HTTP_X_HOOKRELAY_SIGNATURE"]
128
+ secret = "whsec_your_endpoint_signing_secret"
129
+
130
+ unless HookSniff.verify_signature(payload, signature, secret)
131
+ halt 401, { error: "Invalid signature" }.to_json
132
+ end
133
+
134
+ data = JSON.parse(payload)
135
+ puts "Received event: #{data['event']}"
136
+ content_type :json
137
+ { received: true }.to_json
138
+ end
139
+ ```
140
+
141
+ ### Standard Webhooks Verification
142
+
143
+ For Standard Webhooks compatible verification:
144
+
145
+ ```ruby
146
+ result = HookSniff.verify_webhook(
147
+ payload: request.body.read,
148
+ msg_id: request.env["HTTP_WEBHOOK_ID"],
149
+ timestamp: request.env["HTTP_WEBHOOK_TIMESTAMP"],
150
+ signature_header: request.env["HTTP_WEBHOOK_SIGNATURE"],
151
+ secret: "whsec_..."
152
+ )
153
+
154
+ unless result[:valid]
155
+ halt 401, { error: result[:error] }.to_json
156
+ end
157
+
158
+ puts "Event: #{result[:payload]['event']}"
159
+ ```
160
+
161
+ ## Error Handling
162
+
163
+ ```ruby
164
+ begin
165
+ delivery = client.webhooks.send(
166
+ endpoint_id: "nonexistent",
167
+ event: "test.event",
168
+ data: { test: true }
169
+ )
170
+ rescue HookSniff::AuthenticationError => e
171
+ puts "Invalid API key"
172
+ rescue HookSniff::NotFoundError => e
173
+ puts "Endpoint not found"
174
+ rescue HookSniff::RateLimitError => e
175
+ puts "Rate limit exceeded - try again later"
176
+ rescue HookSniff::ValidationError => e
177
+ puts "Invalid request: #{e.message}"
178
+ rescue HookSniff::PayloadTooLargeError => e
179
+ puts "Payload exceeds maximum size"
180
+ end
181
+ ```
182
+
183
+ ## API Reference
184
+
185
+ ### `HookSniff::Client.new(api_key:, base_url: nil, timeout: nil)`
186
+
187
+ | Option | Type | Default | Description |
188
+ |--------|------|---------|-------------|
189
+ | `api_key` | `String` | required | Your HookSniff API key |
190
+ | `base_url` | `String` | `https://hooksniff-api-1046140057667.europe-west1.run.app/v1` | API base URL |
191
+ | `timeout` | `Integer` | `30` | Request timeout in seconds |
192
+
193
+ ### `client.endpoints`
194
+
195
+ - `.create(url:, description: nil, retry_policy: nil)` → `Hash`
196
+ - `.get(endpoint_id)` → `Hash`
197
+ - `.list` → `Array<Hash>`
198
+ - `.delete(endpoint_id)` → `Boolean`
199
+
200
+ ### `client.webhooks`
201
+
202
+ - `.send(endpoint_id:, data:, event: nil)` → `Hash`
203
+ - `.get(delivery_id)` → `Hash`
204
+ - `.list(status: nil, page: 1, per_page: 20)` → `Hash`
205
+ - `.replay(delivery_id)` → `Hash`
206
+ - `.batch(webhooks)` → `Hash`
207
+ - `.attempts(delivery_id)` → `Array<Hash>`
208
+ - `.export(format: nil, status: nil, date_from: nil, date_to: nil)` → `Array<Hash> | String`
209
+
210
+ ### `HookSniff.verify_signature(payload, signature, secret)` → `Boolean`
211
+
212
+ Verify a webhook signature using HMAC-SHA256.
213
+
214
+ ### `HookSniff.verify_webhook(payload:, msg_id:, timestamp:, signature_header:, secret:, tolerance_secs: 300)` → `Hash`
215
+
216
+ Verify a webhook using Standard Webheaders headers.
217
+
218
+ ## License
219
+
220
+ MIT
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "models"
4
+
5
+ module HookSniff
6
+ class Client
7
+ attr_reader :endpoints, :webhooks
8
+
9
+ def initialize(api_key:, base_url: nil, timeout: nil)
10
+ @api_key = api_key
11
+ @base_url = (base_url || DEFAULT_BASE_URL).chomp("/")
12
+ @timeout = timeout || DEFAULT_TIMEOUT
13
+ @endpoints = EndpointsResource.new(self)
14
+ @webhooks = WebhooksResource.new(self)
15
+ end
16
+
17
+ # Get platform statistics
18
+ def stats
19
+ resp = request(:get, "/stats")
20
+ Models::Stats.new(resp)
21
+ end
22
+
23
+ # @api internal
24
+ def request(method, path, body: nil)
25
+ uri = URI("#{@base_url}#{path}")
26
+ http = Net::HTTP.new(uri.host, uri.port)
27
+ http.use_ssl = (uri.scheme == "https")
28
+ http.open_timeout = @timeout
29
+ http.read_timeout = @timeout
30
+
31
+ case method
32
+ when :get
33
+ req = Net::HTTP::Get.new(uri)
34
+ when :post
35
+ req = Net::HTTP::Post.new(uri)
36
+ when :delete
37
+ req = Net::HTTP::Delete.new(uri)
38
+ else
39
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
40
+ end
41
+
42
+ req["Authorization"] = "Bearer #{@api_key}"
43
+ req["Content-Type"] = "application/json"
44
+ req["User-Agent"] = "hooksniff-ruby/#{VERSION}"
45
+
46
+ req.body = JSON.generate(body) if body
47
+
48
+ response = http.request(req)
49
+
50
+ case response.code.to_i
51
+ when 200..299
52
+ content_type = response["content-type"] || ""
53
+ if content_type.include?("text/csv")
54
+ response.body
55
+ else
56
+ JSON.parse(response.body) rescue response.body
57
+ end
58
+ when 400
59
+ raise ValidationError, parse_error_message(response)
60
+ when 401
61
+ raise AuthenticationError, parse_error_message(response)
62
+ when 404
63
+ raise NotFoundError, parse_error_message(response)
64
+ when 413
65
+ raise PayloadTooLargeError, parse_error_message(response)
66
+ when 429
67
+ raise RateLimitError, parse_error_message(response)
68
+ else
69
+ raise Error, "HTTP #{response.code}: #{parse_error_message(response)}"
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def parse_error_message(response)
76
+ body = JSON.parse(response.body)
77
+ body.dig("error", "message") || "HTTP #{response.code}"
78
+ rescue
79
+ "HTTP #{response.code}"
80
+ end
81
+ end
82
+
83
+ class EndpointsResource
84
+ def initialize(client)
85
+ @client = client
86
+ end
87
+
88
+ def create(url:, description: nil, retry_policy: nil)
89
+ body = { url: url }
90
+ body[:description] = description if description
91
+ if retry_policy
92
+ body[:retry_policy] = {
93
+ max_attempts: retry_policy[:max_attempts],
94
+ backoff: retry_policy[:backoff],
95
+ initial_delay_secs: retry_policy[:initial_delay_secs],
96
+ max_delay_secs: retry_policy[:max_delay_secs]
97
+ }.compact
98
+ end
99
+
100
+ resp = @client.request(:post, "/endpoints", body: body)
101
+ Models::Endpoint.new(resp)
102
+ end
103
+
104
+ def get(endpoint_id)
105
+ resp = @client.request(:get, "/endpoints/#{endpoint_id}")
106
+ Models::Endpoint.new(resp)
107
+ end
108
+
109
+ def list(page: 1, per_page: 20)
110
+ params = { page: page.to_s, per_page: per_page.to_s }
111
+ query = URI.encode_www_form(params)
112
+ resp = @client.request(:get, "/endpoints?#{query}")
113
+ {
114
+ endpoints: (resp["endpoints"] || resp).map { |ep| Models::Endpoint.new(ep) },
115
+ total: resp["total"] || 0,
116
+ page: resp["page"] || page,
117
+ per_page: resp["per_page"] || per_page
118
+ }
119
+ end
120
+
121
+ def delete(endpoint_id)
122
+ resp = @client.request(:delete, "/endpoints/#{endpoint_id}")
123
+ resp["deleted"] != false
124
+ end
125
+
126
+ def rotate_secret(endpoint_id)
127
+ @client.request(:post, "/endpoints/#{endpoint_id}/rotate-secret")
128
+ end
129
+
130
+ private
131
+ end
132
+
133
+ class WebhooksResource
134
+ def initialize(client)
135
+ @client = client
136
+ end
137
+
138
+ # Send a webhook
139
+ def send(endpoint_id:, event: nil, data:)
140
+ body = { endpoint_id: endpoint_id, data: data }
141
+ body[:event] = event if event
142
+ resp = @client.request(:post, "/webhooks", body: body)
143
+ Models::Delivery.new(resp)
144
+ end
145
+
146
+ # Get a delivery by ID
147
+ def get(delivery_id)
148
+ resp = @client.request(:get, "/webhooks/#{delivery_id}")
149
+ Models::Delivery.new(resp)
150
+ end
151
+
152
+ # List deliveries with optional filters
153
+ def list(status: nil, page: 1, per_page: 20)
154
+ params = { page: page.to_s, per_page: per_page.to_s }
155
+ params[:status] = status if status
156
+ query = URI.encode_www_form(params)
157
+ resp = @client.request(:get, "/webhooks?#{query}")
158
+ Models::DeliveryList.new(resp)
159
+ end
160
+
161
+ # Replay a delivery
162
+ def replay(delivery_id)
163
+ resp = @client.request(:post, "/webhooks/#{delivery_id}/replay")
164
+ Models::Delivery.new(resp)
165
+ end
166
+
167
+ # Send multiple webhooks in a batch
168
+ def batch(webhooks)
169
+ body = {
170
+ webhooks: webhooks.map do |w|
171
+ item = { endpoint_id: w[:endpoint_id], data: w[:data] }
172
+ item[:event] = w[:event] if w[:event]
173
+ item
174
+ end
175
+ }
176
+ resp = @client.request(:post, "/webhooks/batch", body: body)
177
+ Models::BatchResult.new(resp)
178
+ end
179
+
180
+ # Get delivery attempts
181
+ def attempts(delivery_id)
182
+ resp = @client.request(:get, "/webhooks/#{delivery_id}/attempts")
183
+ resp.map { |a| Models::DeliveryAttempt.new(a) }
184
+ end
185
+
186
+ # Export deliveries
187
+ def export(format: nil, status: nil, date_from: nil, date_to: nil)
188
+ params = {}
189
+ params[:format] = format if format
190
+ params[:status] = status if status
191
+ params[:date_from] = date_from if date_from
192
+ params[:date_to] = date_to if date_to
193
+ query = URI.encode_www_form(params)
194
+ query = "?#{query}" unless query.empty?
195
+
196
+ resp = @client.request(:get, "/webhooks/export#{query}")
197
+ return resp if format == "csv"
198
+
199
+ resp.map { |d| Models::Delivery.new(d) }
200
+ end
201
+
202
+ # Search deliveries with filters
203
+ def search(query: nil, event: nil, status: nil, endpoint_id: nil, page: 1, per_page: 20)
204
+ params = { page: page.to_s, per_page: per_page.to_s }
205
+ params[:q] = query if query
206
+ params[:event] = event if event
207
+ params[:status] = status if status
208
+ params[:endpoint_id] = endpoint_id if endpoint_id
209
+ query_str = URI.encode_www_form(params)
210
+ @client.request(:get, "/search?#{query_str}")
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HookSniff
4
+ class Error < StandardError
5
+ attr_reader :status_code, :error_code
6
+
7
+ def initialize(message, status_code: nil, error_code: nil)
8
+ super(message)
9
+ @status_code = status_code
10
+ @error_code = error_code
11
+ end
12
+ end
13
+
14
+ class AuthenticationError < Error
15
+ def initialize(message = "Unauthorized: invalid or missing API key")
16
+ super(message, status_code: 401, error_code: "UNAUTHORIZED")
17
+ end
18
+ end
19
+
20
+ class NotFoundError < Error
21
+ def initialize(message = "Resource not found")
22
+ super(message, status_code: 404, error_code: "NOT_FOUND")
23
+ end
24
+ end
25
+
26
+ class RateLimitError < Error
27
+ def initialize(message = "Rate limit exceeded")
28
+ super(message, status_code: 429, error_code: "RATE_LIMIT_EXCEEDED")
29
+ end
30
+ end
31
+
32
+ class ValidationError < Error
33
+ def initialize(message = "Bad request")
34
+ super(message, status_code: 400, error_code: "BAD_REQUEST")
35
+ end
36
+ end
37
+
38
+ class PayloadTooLargeError < Error
39
+ def initialize(message = "Payload too large")
40
+ super(message, status_code: 413, error_code: "PAYLOAD_TOO_LARGE")
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HookSniff
4
+ module Models
5
+ class Endpoint
6
+ attr_reader :id, :url, :description, :is_active, :retry_policy, :created_at
7
+
8
+ def initialize(data)
9
+ @id = data["id"]
10
+ @url = data["url"]
11
+ @description = data["description"]
12
+ @is_active = data["is_active"]
13
+ @retry_policy = data["retry_policy"] ? RetryPolicy.new(data["retry_policy"]) : nil
14
+ @created_at = data["created_at"]
15
+ end
16
+
17
+ def to_h
18
+ {
19
+ id: @id,
20
+ url: @url,
21
+ description: @description,
22
+ is_active: @is_active,
23
+ retry_policy: @retry_policy&.to_h,
24
+ created_at: @created_at
25
+ }
26
+ end
27
+ end
28
+
29
+ class RetryPolicy
30
+ attr_reader :max_attempts, :backoff, :initial_delay_secs, :max_delay_secs
31
+
32
+ def initialize(data)
33
+ @max_attempts = data["max_attempts"]
34
+ @backoff = data["backoff"]
35
+ @initial_delay_secs = data["initial_delay_secs"]
36
+ @max_delay_secs = data["max_delay_secs"]
37
+ end
38
+
39
+ def to_h
40
+ {
41
+ max_attempts: @max_attempts,
42
+ backoff: @backoff,
43
+ initial_delay_secs: @initial_delay_secs,
44
+ max_delay_secs: @max_delay_secs
45
+ }.compact
46
+ end
47
+ end
48
+
49
+ class Delivery
50
+ attr_reader :id, :endpoint_id, :event, :status, :attempt_count, :response_status, :replay_count, :created_at
51
+
52
+ def initialize(data)
53
+ @id = data["id"]
54
+ @endpoint_id = data["endpoint_id"]
55
+ @event = data["event"]
56
+ @status = data["status"]
57
+ @attempt_count = data["attempt_count"] || 0
58
+ @response_status = data["response_status"]
59
+ @replay_count = data["replay_count"] || 0
60
+ @created_at = data["created_at"]
61
+ end
62
+
63
+ def to_h
64
+ {
65
+ id: @id,
66
+ endpoint_id: @endpoint_id,
67
+ event: @event,
68
+ status: @status,
69
+ attempt_count: @attempt_count,
70
+ response_status: @response_status,
71
+ replay_count: @replay_count,
72
+ created_at: @created_at
73
+ }
74
+ end
75
+ end
76
+
77
+ class DeliveryAttempt
78
+ attr_reader :id, :attempt_number, :status_code, :response_body, :duration_ms, :error_message, :created_at
79
+
80
+ def initialize(data)
81
+ @id = data["id"]
82
+ @attempt_number = data["attempt_number"]
83
+ @status_code = data["status_code"]
84
+ @response_body = data["response_body"]
85
+ @duration_ms = data["duration_ms"]
86
+ @error_message = data["error_message"]
87
+ @created_at = data["created_at"]
88
+ end
89
+
90
+ def to_h
91
+ {
92
+ id: @id,
93
+ attempt_number: @attempt_number,
94
+ status_code: @status_code,
95
+ response_body: @response_body,
96
+ duration_ms: @duration_ms,
97
+ error_message: @error_message,
98
+ created_at: @created_at
99
+ }
100
+ end
101
+ end
102
+
103
+ class DeliveryList
104
+ attr_reader :deliveries, :total, :page, :per_page
105
+
106
+ def initialize(data)
107
+ @deliveries = (data["deliveries"] || []).map { |d| Delivery.new(d) }
108
+ @total = data["total"]
109
+ @page = data["page"]
110
+ @per_page = data["per_page"]
111
+ end
112
+ end
113
+
114
+ class BatchResult
115
+ attr_reader :deliveries, :errors
116
+
117
+ def initialize(data)
118
+ @deliveries = (data["deliveries"] || []).map { |d| Delivery.new(d) }
119
+ @errors = data["errors"] || []
120
+ end
121
+ end
122
+
123
+ class Stats
124
+ attr_reader :total_deliveries, :delivered, :failed, :pending, :success_rate, :endpoints_count
125
+
126
+ def initialize(data)
127
+ @total_deliveries = data["total_deliveries"]
128
+ @delivered = data["delivered"]
129
+ @failed = data["failed"]
130
+ @pending = data["pending"]
131
+ @success_rate = data["success_rate"]
132
+ @endpoints_count = data["endpoints_count"]
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HookSniff
4
+ # Verify a webhook signature using HMAC-SHA256.
5
+ #
6
+ # @param payload [String] The raw request body
7
+ # @param signature [String] The signature from the X-Hookrelay-Signature header
8
+ # @param secret [String] The endpoint's signing secret (starts with "whsec_")
9
+ # @return [Boolean] true if the signature is valid
10
+ def self.verify_signature(payload, signature, secret)
11
+ return false if payload.nil? || payload.empty?
12
+ return false if signature.nil? || signature.empty?
13
+ return false if secret.nil? || secret.empty?
14
+
15
+ expected_hex = signature.start_with?("sha256=") ? signature[7..] : signature
16
+
17
+ computed = OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
18
+
19
+ # Constant-time comparison to prevent timing attacks
20
+ secure_compare(computed, expected_hex)
21
+ rescue
22
+ false
23
+ end
24
+
25
+ # Verify a webhook signature from an incoming request (Standard Webhooks + Svix compatible).
26
+ #
27
+ # Supports both Standard Webheaders headers (webhook-id, webhook-signature, webhook-timestamp)
28
+ # and Svix headers (svix-id, svix-signature, svix-timestamp) as fallback.
29
+ #
30
+ # @param payload [String] The raw request body
31
+ # @param headers [Hash] The request headers (symbol or string keys)
32
+ # @param secret [String] The endpoint's signing secret
33
+ # @param tolerance_secs [Integer] Max age in seconds (default: 300)
34
+ # @return [Hash] { valid: bool, payload: parsed_data, error: string }
35
+ def self.verify_webhook_from_headers(payload:, headers:, secret:, tolerance_secs: 300)
36
+ # Normalize header keys to lowercase strings
37
+ normalized = headers.transform_keys { |k| k.to_s.downcase }
38
+
39
+ msg_id = normalized["webhook-id"]
40
+ timestamp = normalized["webhook-timestamp"]
41
+ signature_header = normalized["webhook-signature"]
42
+
43
+ # Fallback to Svix headers
44
+ unless msg_id && timestamp && signature_header
45
+ msg_id ||= normalized["svix-id"]
46
+ timestamp ||= normalized["svix-timestamp"]
47
+ signature_header ||= normalized["svix-signature"]
48
+ end
49
+
50
+ verify_webhook(
51
+ payload: payload,
52
+ msg_id: msg_id,
53
+ timestamp: timestamp,
54
+ signature_header: signature_header,
55
+ secret: secret,
56
+ tolerance_secs: tolerance_secs,
57
+ )
58
+ end
59
+
60
+ # Verify a webhook signature from an incoming request (Standard Webhooks compatible).
61
+ #
62
+ # @param payload [String] The raw request body
63
+ # @param msg_id [String] The webhook-id header
64
+ # @param timestamp [String] The webhook-timestamp header
65
+ # @param signature_header [String] The webhook-signature header
66
+ # @param secret [String] The endpoint's signing secret
67
+ # @param tolerance_secs [Integer] Max age in seconds (default: 300)
68
+ # @return [Hash] { valid: bool, payload: parsed_data, error: string }
69
+ def self.verify_webhook(payload:, msg_id:, timestamp:, signature_header:, secret:, tolerance_secs: 300)
70
+ return { valid: false, error: "Missing webhook-id header" } if msg_id.nil? || msg_id.empty?
71
+ return { valid: false, error: "Missing webhook-timestamp header" } if timestamp.nil? || timestamp.empty?
72
+ return { valid: false, error: "Missing webhook-signature header" } if signature_header.nil? || signature_header.empty?
73
+ return { valid: false, error: "Missing request body" } if payload.nil? || payload.empty?
74
+
75
+ ts = timestamp.to_i
76
+ return { valid: false, error: "Invalid webhook timestamp" } if ts == 0
77
+
78
+ now = Time.now.to_i
79
+
80
+ if now - ts > tolerance_secs
81
+ return { valid: false, error: "Message timestamp too old" }
82
+ end
83
+ if ts > now + tolerance_secs
84
+ return { valid: false, error: "Message timestamp too new" }
85
+ end
86
+
87
+ # Compute expected signature
88
+ signed_content = "#{msg_id}.#{timestamp}.#{payload}"
89
+ secret_bytes = decode_secret(secret)
90
+
91
+ expected_sig = Base64.strict_encode64(
92
+ OpenSSL::HMAC.digest("SHA256", secret_bytes, signed_content)
93
+ )
94
+ expected_full = "v1,#{expected_sig}"
95
+
96
+ # Check each signature in the header (space-separated)
97
+ signatures = signature_header.split(" ")
98
+ verified = signatures.any? do |sig|
99
+ sig_stripped = sig.strip
100
+ next unless sig_stripped.start_with?("v1,")
101
+ secure_compare(sig_stripped, expected_full)
102
+ end
103
+
104
+ unless verified
105
+ return { valid: false, error: "Invalid webhook signature" }
106
+ end
107
+
108
+ # Parse the payload
109
+ begin
110
+ parsed = JSON.parse(payload)
111
+ { valid: true, payload: parsed }
112
+ rescue JSON::ParserError
113
+ { valid: true, payload: payload }
114
+ end
115
+ end
116
+
117
+ private_class_method def self.decode_secret(secret)
118
+ stripped = secret.start_with?("whsec_") ? secret[6..] : secret
119
+ # Add padding in case secret is unpadded base64
120
+ Base64.strict_decode64(stripped + "==")
121
+ rescue ArgumentError
122
+ secret.bytes.pack("C*")
123
+ end
124
+
125
+ # Constant-time string comparison
126
+ def self.secure_compare(a, b)
127
+ return false if a.nil? || b.nil?
128
+ return false if a.bytesize != b.bytesize
129
+
130
+ result = 0
131
+ a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
132
+ result == 0
133
+ end
134
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HookSniff
4
+ VERSION = "0.1.0"
5
+ end
data/lib/hooksniff.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "openssl"
7
+
8
+ require_relative "hooksniff/version"
9
+ require_relative "hooksniff/errors"
10
+ require_relative "hooksniff/client"
11
+ require_relative "hooksniff/verification"
12
+
13
+ module HookSniff
14
+ # Default API base URL
15
+ DEFAULT_BASE_URL = "https://hooksniff-api-1046140057667.europe-west1.run.app/v1"
16
+
17
+ # Default request timeout in seconds
18
+ DEFAULT_TIMEOUT = 30
19
+ end
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hooksniff
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - HookSniff
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: net-http
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 0.3.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 0.3.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: json
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 2.0.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 2.0.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: uri
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 0.12.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: 0.12.0
55
+ description: Ruby SDK for the HookSniff webhook delivery platform. Provides API client,
56
+ webhook sending, delivery management, and signature verification.
57
+ email:
58
+ - support@hooksniff.dev
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - README.md
64
+ - lib/hooksniff.rb
65
+ - lib/hooksniff/client.rb
66
+ - lib/hooksniff/errors.rb
67
+ - lib/hooksniff/models.rb
68
+ - lib/hooksniff/verification.rb
69
+ - lib/hooksniff/version.rb
70
+ homepage: https://github.com/hooksniff/hooksniff-ruby
71
+ licenses:
72
+ - MIT
73
+ metadata: {}
74
+ post_install_message:
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 2.7.0
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 3.5.3
90
+ signing_key:
91
+ specification_version: 4
92
+ summary: Official Ruby client for HookSniff webhook delivery service
93
+ test_files: []