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 +7 -0
- data/README.md +220 -0
- data/lib/hooksniff/client.rb +213 -0
- data/lib/hooksniff/errors.rb +43 -0
- data/lib/hooksniff/models.rb +136 -0
- data/lib/hooksniff/verification.rb +134 -0
- data/lib/hooksniff/version.rb +5 -0
- data/lib/hooksniff.rb +19 -0
- metadata +93 -0
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
|
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: []
|