rails_webhook_outbox 0.1.0 → 0.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.
- checksums.yaml +4 -4
- data/README.md +203 -5
- data/app/jobs/rails_webhook_outbox/delivery_job.rb +55 -2
- data/app/models/rails_webhook_outbox/delivery.rb +9 -0
- data/app/models/rails_webhook_outbox/subscription.rb +44 -1
- data/db/migrate/20260629000002_add_idempotency_key_to_webhook_outbox_deliveries.rb +6 -0
- data/db/migrate/20260701000001_add_secret_rotation_to_webhook_outbox_subscriptions.rb +6 -0
- data/db/migrate/20260701000002_add_consecutive_failures_to_webhook_outbox_subscriptions.rb +5 -0
- data/lib/generators/rails_webhook_outbox/install/install_generator.rb +2 -2
- data/lib/generators/rails_webhook_outbox/install/templates/{create_webhook_outbox_deliveries.rb → create_webhook_outbox_deliveries.rb.tt} +2 -1
- data/lib/generators/rails_webhook_outbox/install/templates/{create_webhook_outbox_subscriptions.rb → create_webhook_outbox_subscriptions.rb.tt} +3 -0
- data/lib/generators/rails_webhook_outbox/install/templates/initializer.rb +8 -0
- data/lib/rails_webhook_outbox/configuration.rb +14 -1
- data/lib/rails_webhook_outbox/dispatchable.rb +8 -0
- data/lib/rails_webhook_outbox/payload_size_error.rb +7 -0
- data/lib/rails_webhook_outbox/rspec_matchers.rb +32 -0
- data/lib/rails_webhook_outbox/secret_rotation_error.rb +8 -0
- data/lib/rails_webhook_outbox/sender.rb +3 -2
- data/lib/rails_webhook_outbox/signature.rb +2 -2
- data/lib/rails_webhook_outbox/testing.rb +13 -0
- data/lib/rails_webhook_outbox/version.rb +1 -1
- data/lib/rails_webhook_outbox.rb +27 -0
- data/lib/tasks/rails_webhook_outbox_tasks.rake +51 -4
- metadata +10 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72234fe9faabd82b982170783792dd9d50188b8a727b72f46ab30e5f8d36929a
|
|
4
|
+
data.tar.gz: bf7c3b453aaf3c66d1ce4a06fb2a72d169dfcae330cc3ba8819dbe75de020e67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6363ae17aa5037b3c58e8169d97c7bb6c997f5e8e515a963aa5964f9aba9ffe29c4e09805f1bcd69e69f569e7887b2e1f7c69119c93c25f8004258809ab6581e
|
|
7
|
+
data.tar.gz: 7009433848a306ff1465d73d526a9378230ca7730fa7a9bcc4aa3159091bdaab663f442e2124ff3b6507a9fb63adb65bda814607a3d56fb297ffc04b5018cc25
|
data/README.md
CHANGED
|
@@ -17,10 +17,16 @@ A Rails engine for sending outgoing webhooks with HMAC signing, ActiveJob-based
|
|
|
17
17
|
- [Subscriptions](#subscriptions)
|
|
18
18
|
- [Deliveries](#deliveries)
|
|
19
19
|
- [Async Delivery](#async-delivery)
|
|
20
|
+
- [Instrumentation](#instrumentation)
|
|
21
|
+
- [Logging](#logging)
|
|
20
22
|
- [HTTP Request Format](#http-request-format)
|
|
21
23
|
- [HMAC Signing](#hmac-signing)
|
|
24
|
+
- [Secret Rotation](#secret-rotation)
|
|
25
|
+
- [Circuit Breaker](#circuit-breaker)
|
|
26
|
+
- [Rake Tasks](#rake-tasks)
|
|
22
27
|
- [Usage](#usage)
|
|
23
28
|
- [Manual Dispatch](#manual-dispatch)
|
|
29
|
+
- [Testing](#testing)
|
|
24
30
|
- [Development](#development)
|
|
25
31
|
- [Dummy App](#dummy-app)
|
|
26
32
|
- [Contributing](#contributing)
|
|
@@ -64,9 +70,16 @@ RailsWebhookOutbox.configure do |config|
|
|
|
64
70
|
config.retry_backoff = :exponential
|
|
65
71
|
config.request_timeout = 5
|
|
66
72
|
config.delivery_job_queue = :webhooks
|
|
73
|
+
config.max_payload_size = 65_536 # bytes; set to nil or 0 to disable
|
|
74
|
+
config.secret_rotation_grace_period = 24.hours
|
|
75
|
+
config.circuit_breaker_threshold = 10 # consecutive permanent failures before auto-disabling; nil or 0 disables
|
|
67
76
|
end
|
|
68
77
|
```
|
|
69
78
|
|
|
79
|
+
When `config.events` is set, both `dispatch` and `Dispatchable` callbacks will raise `ArgumentError` if the event name is not in the list. Leave `config.events` empty to skip validation entirely.
|
|
80
|
+
|
|
81
|
+
If the JSON-serialised payload exceeds `config.max_payload_size` bytes, `RailsWebhookOutbox::PayloadSizeError` is raised before any `Delivery` record is created or job enqueued. The default limit is 64 KB (`65_536` bytes).
|
|
82
|
+
|
|
70
83
|
[Back to top](#table-of-contents)
|
|
71
84
|
|
|
72
85
|
## Subscriptions
|
|
@@ -146,7 +159,7 @@ The job uses polynomial backoff (`:polynomially_longer`) between retries — wai
|
|
|
146
159
|
| n (`max_retries`) | non-2xx response | `failed` — no further retry |
|
|
147
160
|
| any | 2xx response | `delivered` |
|
|
148
161
|
|
|
149
|
-
Every attempt (success or failure) increments `delivery.attempts` and stores the `response_code` and `response_body`. Successful deliveries also set `delivered_at`.
|
|
162
|
+
Every attempt (success or failure) increments `delivery.attempts` and stores the `response_code` and `response_body`. Successful deliveries also set `delivered_at`. Retryable failures set `next_retry_at` to the estimated time of the next attempt (based on the polynomial formula `executions⁴ + 2` seconds); it is cleared to `nil` once all retries are exhausted.
|
|
150
163
|
|
|
151
164
|
Enqueue a delivery manually:
|
|
152
165
|
|
|
@@ -156,6 +169,69 @@ RailsWebhookOutbox::DeliveryJob.perform_later(delivery)
|
|
|
156
169
|
|
|
157
170
|
[Back to top](#table-of-contents)
|
|
158
171
|
|
|
172
|
+
## Instrumentation
|
|
173
|
+
|
|
174
|
+
`DeliveryJob` publishes `ActiveSupport::Notifications` events you can subscribe to for logging, metrics, or alerting:
|
|
175
|
+
|
|
176
|
+
| Event | When |
|
|
177
|
+
|-------|------|
|
|
178
|
+
| `webhook.delivered.rails_webhook_outbox` | Delivery succeeded (2xx response) |
|
|
179
|
+
| `webhook.failed.rails_webhook_outbox` | All retries exhausted — permanent failure |
|
|
180
|
+
| `webhook.circuit_breaker_tripped.rails_webhook_outbox` | A permanent failure auto-disabled the subscription — see [Circuit Breaker](#circuit-breaker) |
|
|
181
|
+
|
|
182
|
+
`webhook.delivered` / `webhook.failed` payloads include:
|
|
183
|
+
|
|
184
|
+
| Key | Type | Description |
|
|
185
|
+
|-----|------|-------------|
|
|
186
|
+
| `event` | String | Webhook event name, e.g. `"order.created"` |
|
|
187
|
+
| `subscription_id` | Integer | ID of the `Subscription` record |
|
|
188
|
+
| `delivery_id` | Integer | ID of the `Delivery` record |
|
|
189
|
+
| `duration` | Integer | HTTP round-trip time in milliseconds |
|
|
190
|
+
|
|
191
|
+
`webhook.circuit_breaker_tripped` payloads include `subscription_id` and `consecutive_failures`.
|
|
192
|
+
|
|
193
|
+
Subscribe in an initializer:
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
ActiveSupport::Notifications.subscribe("webhook.delivered.rails_webhook_outbox") do |*args|
|
|
197
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
198
|
+
Rails.logger.info "[webhook] delivered #{event.payload[:event]} in #{event.payload[:duration]}ms"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
ActiveSupport::Notifications.subscribe("webhook.failed.rails_webhook_outbox") do |*args|
|
|
202
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
203
|
+
Sentry.capture_message("Webhook permanently failed", extra: event.payload)
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
[Back to top](#table-of-contents)
|
|
208
|
+
|
|
209
|
+
## Logging
|
|
210
|
+
|
|
211
|
+
`Sender` and `DeliveryJob` emit structured `Rails.logger` output for every delivery lifecycle event. All lines are prefixed `[RailsWebhookOutbox]` in key=value format, making them easy to grep and compatible with Logfmt-aware log aggregators (Datadog, Papertrail, etc.).
|
|
212
|
+
|
|
213
|
+
| Source | Level | When | Keys logged |
|
|
214
|
+
|--------|-------|------|-------------|
|
|
215
|
+
| `Sender` | `info` | Before each HTTP call | `event`, `key` (idempotency key), `url` |
|
|
216
|
+
| `DeliveryJob` | `info` | Successful delivery | `event`, `delivery_id`, `subscription_id`, `status`, `duration` |
|
|
217
|
+
| `DeliveryJob` | `warn` | Retryable failure | `event`, `delivery_id`, `subscription_id`, `status`, `attempt`, `next_retry_at` |
|
|
218
|
+
| `DeliveryJob` | `error` | Permanent failure (all retries exhausted) | `event`, `delivery_id`, `subscription_id`, `status`, `attempts` |
|
|
219
|
+
| `DeliveryJob` | `warn` | Circuit breaker tripped | `subscription_id`, `consecutive_failures` |
|
|
220
|
+
|
|
221
|
+
Example lines:
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
[RailsWebhookOutbox] attempt event=order.created key=550e8400-e29b-41d4-a716-446655440000 url=https://example.com/webhooks
|
|
225
|
+
[RailsWebhookOutbox] delivered event=order.created delivery_id=1 subscription_id=1 status=200 duration=45ms
|
|
226
|
+
[RailsWebhookOutbox] retry event=order.created delivery_id=1 subscription_id=1 status=503 attempt=1 next_retry_at=2026-07-01T00:00:13Z
|
|
227
|
+
[RailsWebhookOutbox] failed event=order.created delivery_id=1 subscription_id=1 status=503 attempts=3
|
|
228
|
+
[RailsWebhookOutbox] circuit_breaker_tripped subscription_id=1 consecutive_failures=10
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
No configuration is required — logging is always on and respects your application's log level.
|
|
232
|
+
|
|
233
|
+
[Back to top](#table-of-contents)
|
|
234
|
+
|
|
159
235
|
## HTTP Request Format
|
|
160
236
|
|
|
161
237
|
Each webhook delivery is an HTTP POST to the subscription URL with the following headers and body:
|
|
@@ -175,6 +251,8 @@ X-Webhook-Timestamp: 1719100800
|
|
|
175
251
|
}
|
|
176
252
|
```
|
|
177
253
|
|
|
254
|
+
`X-Webhook-Delivery` is the delivery's `idempotency_key` — a UUID generated once when the `Delivery` record is created and reused on every retry attempt. Subscribers can use this value to deduplicate incoming webhooks.
|
|
255
|
+
|
|
178
256
|
Non-2xx responses raise `RailsWebhookOutbox::DeliveryError`, which carries `response_code` and `response_body` for logging and retry decisions.
|
|
179
257
|
|
|
180
258
|
[Back to top](#table-of-contents)
|
|
@@ -187,11 +265,14 @@ Every outgoing request includes an `X-Webhook-Signature` header (configurable) c
|
|
|
187
265
|
X-Webhook-Signature: sha256=a1b2c3d4...
|
|
188
266
|
```
|
|
189
267
|
|
|
190
|
-
Subscribers can verify the signature
|
|
268
|
+
Subscribers can verify the signature. The header may contain more than one comma-separated
|
|
269
|
+
`algorithm=digest` pair while a subscription's secret is [rotating](#secret-rotation), so check
|
|
270
|
+
each one and accept the request if any match:
|
|
191
271
|
|
|
192
272
|
```ruby
|
|
193
|
-
expected = RailsWebhookOutbox::Signature.
|
|
194
|
-
|
|
273
|
+
expected = RailsWebhookOutbox::Signature.sign(raw_body, subscription.secret, :sha256)
|
|
274
|
+
signatures = request.headers["X-Webhook-Signature"].to_s.split(",")
|
|
275
|
+
valid = signatures.any? { |sig| Rack::Utils.secure_compare(sig, "sha256=#{expected}") }
|
|
195
276
|
```
|
|
196
277
|
|
|
197
278
|
You can also call the primitives directly:
|
|
@@ -206,6 +287,83 @@ RailsWebhookOutbox::Signature.header_value(payload, secret)
|
|
|
206
287
|
# => "sha256=a1b2c3d4..."
|
|
207
288
|
```
|
|
208
289
|
|
|
290
|
+
`header_value` also accepts an array of secrets, signing the payload with each one and joining the
|
|
291
|
+
results with a comma — this is how dual-secret signing works during [secret rotation](#secret-rotation).
|
|
292
|
+
|
|
293
|
+
[Back to top](#table-of-contents)
|
|
294
|
+
|
|
295
|
+
## Secret Rotation
|
|
296
|
+
|
|
297
|
+
Rotate a subscription's HMAC secret without dropping any webhook deliveries:
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
sub.rotate_secret!
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
This generates a new secret and moves the old one to `previous_secret`, valid until
|
|
304
|
+
`previous_secret_expires_at` (set from `config.secret_rotation_grace_period`, default 24 hours).
|
|
305
|
+
Pass `grace_period:` to override it for a single rotation:
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
sub.rotate_secret!(grace_period: 3.days)
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Calling `rotate_secret!` again while a previous secret is still within its grace period raises
|
|
312
|
+
`RailsWebhookOutbox::SecretRotationError`, since the schema only holds one previous secret — doing
|
|
313
|
+
so would silently invalidate a secret subscribers may not have finished transitioning to yet. Pass
|
|
314
|
+
`force: true` to rotate anyway and discard it immediately.
|
|
315
|
+
|
|
316
|
+
While `previous_secret` is still active, every outgoing request is signed with **both** secrets —
|
|
317
|
+
`X-Webhook-Signature` carries a comma-separated list of `algorithm=digest` pairs:
|
|
318
|
+
|
|
319
|
+
```
|
|
320
|
+
X-Webhook-Signature: sha256=<new-secret-digest>,sha256=<previous-secret-digest>
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
See [HMAC Signing](#hmac-signing) for how subscribers should verify a header that may contain more
|
|
324
|
+
than one signature.
|
|
325
|
+
|
|
326
|
+
[Back to top](#table-of-contents)
|
|
327
|
+
|
|
328
|
+
## Circuit Breaker
|
|
329
|
+
|
|
330
|
+
Each `Subscription` tracks `consecutive_failures` — how many deliveries in a row have permanently
|
|
331
|
+
failed (all retries exhausted). A successful delivery resets it to zero.
|
|
332
|
+
|
|
333
|
+
Once `consecutive_failures` reaches `config.circuit_breaker_threshold` (default 10), the
|
|
334
|
+
subscription is automatically disabled (`active: false`) so a persistently failing endpoint stops
|
|
335
|
+
being hammered — deliveries already in flight are skipped rather than retried further once their
|
|
336
|
+
subscription is disabled — and `webhook.circuit_breaker_tripped.rails_webhook_outbox` is published:
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
ActiveSupport::Notifications.subscribe("webhook.circuit_breaker_tripped.rails_webhook_outbox") do |*args|
|
|
340
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
341
|
+
Sentry.capture_message("Webhook subscription auto-disabled",
|
|
342
|
+
extra: event.payload.slice(:subscription_id, :consecutive_failures))
|
|
343
|
+
end
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Set `config.circuit_breaker_threshold` to `nil` or `0` to disable auto-disabling entirely. Retryable
|
|
347
|
+
(non-final) failures don't count towards the threshold — only a delivery that has exhausted all
|
|
348
|
+
retries counts as one consecutive failure. Re-enable a tripped subscription with
|
|
349
|
+
`sub.update!(active: true)`; `consecutive_failures` resets to zero immediately on reactivation, so a
|
|
350
|
+
single subsequent failure won't instantly re-trip the breaker.
|
|
351
|
+
|
|
352
|
+
[Back to top](#table-of-contents)
|
|
353
|
+
|
|
354
|
+
## Rake Tasks
|
|
355
|
+
|
|
356
|
+
```bash
|
|
357
|
+
bin/rails webhook_outbox:retry_failed # re-enqueue all failed deliveries for retry
|
|
358
|
+
bin/rails webhook_outbox:list_subscriptions # list subscriptions with status, events, and failure count
|
|
359
|
+
bin/rails webhook_outbox:cleanup[7] # delete delivered/failed deliveries older than N days
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
`retry_failed` resets each failed delivery to `pending` and enqueues a `DeliveryJob`; deliveries
|
|
363
|
+
whose subscription has since been disabled are skipped by the job itself, not by the task.
|
|
364
|
+
`cleanup[days]` requires a positive integer argument and only removes deliveries in a terminal
|
|
365
|
+
state (`delivered` or `failed`) — `pending` deliveries are never deleted.
|
|
366
|
+
|
|
209
367
|
[Back to top](#table-of-contents)
|
|
210
368
|
|
|
211
369
|
## Usage
|
|
@@ -257,12 +415,52 @@ RailsWebhookOutbox.dispatch("payment.completed", {
|
|
|
257
415
|
})
|
|
258
416
|
```
|
|
259
417
|
|
|
260
|
-
`dispatch` finds every active `Subscription` that includes the given event, creates a `Delivery` record for each one, and enqueues a `DeliveryJob`. Subscriptions that are inactive or do not subscribe to the event are skipped silently.
|
|
418
|
+
`dispatch` validates the event name against `config.events` (if configured), then finds every active `Subscription` that includes the given event, creates a `Delivery` record for each one, and enqueues a `DeliveryJob`. Subscriptions that are inactive or do not subscribe to the event are skipped silently.
|
|
419
|
+
|
|
420
|
+
You can also validate an event name directly without dispatching:
|
|
421
|
+
|
|
422
|
+
```ruby
|
|
423
|
+
RailsWebhookOutbox.validate_event!("payment.completed")
|
|
424
|
+
# raises ArgumentError if the event is not in config.events
|
|
425
|
+
```
|
|
261
426
|
|
|
262
427
|
This is the same delivery pipeline used by `Dispatchable` callbacks, so retries, HMAC signing, and delivery logging all apply.
|
|
263
428
|
|
|
264
429
|
[Back to top](#table-of-contents)
|
|
265
430
|
|
|
431
|
+
## Testing
|
|
432
|
+
|
|
433
|
+
Enable test mode in your `rails_helper.rb` to suppress HTTP calls and DB writes during specs:
|
|
434
|
+
|
|
435
|
+
```ruby
|
|
436
|
+
require "rails_webhook_outbox/rspec_matchers"
|
|
437
|
+
|
|
438
|
+
RailsWebhookOutbox.configure { |c| c.test_mode = true }
|
|
439
|
+
|
|
440
|
+
RSpec.configure do |config|
|
|
441
|
+
config.before { RailsWebhookOutbox::Testing.clear_deliveries! }
|
|
442
|
+
end
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
When `test_mode` is `true`, dispatched events are captured in memory instead of creating `Delivery` records or enqueuing jobs. Use the `dispatch_webhook` matcher to assert on them:
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
expect { order.save! }.to dispatch_webhook("order.created")
|
|
449
|
+
expect { order.save! }.to dispatch_webhook("order.created").with_payload({ id: order.id })
|
|
450
|
+
expect { order.save! }.not_to dispatch_webhook("order.updated")
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Inspect captured events directly if needed:
|
|
454
|
+
|
|
455
|
+
```ruby
|
|
456
|
+
RailsWebhookOutbox::Testing.deliveries
|
|
457
|
+
# => [{ event: "order.created", payload: { "id" => 1, ... } }]
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
`DeliveryJob` also respects `test_mode` — if a job is enqueued and performed directly in a test, it marks the delivery as `delivered` without making an HTTP call.
|
|
461
|
+
|
|
462
|
+
[Back to top](#table-of-contents)
|
|
463
|
+
|
|
266
464
|
## Development
|
|
267
465
|
|
|
268
466
|
```bash
|
|
@@ -4,7 +4,18 @@ module RailsWebhookOutbox
|
|
|
4
4
|
retry_on RailsWebhookOutbox::DeliveryError, wait: :polynomially_longer, attempts: :unlimited
|
|
5
5
|
|
|
6
6
|
def perform(delivery)
|
|
7
|
+
return skip_disabled_subscription(delivery) unless delivery.subscription.active?
|
|
8
|
+
|
|
9
|
+
if RailsWebhookOutbox.config.test_mode
|
|
10
|
+
delivery.update!(status: :delivered, attempts: delivery.attempts + 1, delivered_at: Time.current)
|
|
11
|
+
delivery.subscription.record_delivery_success!
|
|
12
|
+
notify("webhook.delivered.rails_webhook_outbox", delivery, 0)
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
7
17
|
response = Sender.call(delivery)
|
|
18
|
+
duration_ms = elapsed_ms(start)
|
|
8
19
|
delivery.update!(
|
|
9
20
|
status: :delivered,
|
|
10
21
|
response_code: response.code.to_i,
|
|
@@ -12,15 +23,57 @@ module RailsWebhookOutbox
|
|
|
12
23
|
delivered_at: Time.current,
|
|
13
24
|
attempts: delivery.attempts + 1
|
|
14
25
|
)
|
|
26
|
+
delivery.subscription.record_delivery_success!
|
|
27
|
+
Rails.logger.info { "[RailsWebhookOutbox] delivered event=#{delivery.event} delivery_id=#{delivery.id} subscription_id=#{delivery.subscription_id} status=#{response.code} duration=#{duration_ms}ms" }
|
|
28
|
+
notify("webhook.delivered.rails_webhook_outbox", delivery, duration_ms)
|
|
15
29
|
rescue DeliveryError => e
|
|
30
|
+
duration_ms = elapsed_ms(start)
|
|
16
31
|
max_retries = RailsWebhookOutbox.config.max_retries
|
|
32
|
+
final = executions >= max_retries
|
|
17
33
|
delivery.update!(
|
|
18
34
|
response_code: e.response_code,
|
|
19
35
|
response_body: e.response_body&.truncate(1000),
|
|
20
36
|
attempts: delivery.attempts + 1,
|
|
21
|
-
status:
|
|
37
|
+
status: final ? :failed : :pending,
|
|
38
|
+
next_retry_at: final ? nil : Time.current + ((executions**4) + 2).seconds
|
|
22
39
|
)
|
|
23
|
-
|
|
40
|
+
if final
|
|
41
|
+
Rails.logger.error { "[RailsWebhookOutbox] failed event=#{delivery.event} delivery_id=#{delivery.id} subscription_id=#{delivery.subscription_id} status=#{e.response_code} attempts=#{delivery.attempts}" }
|
|
42
|
+
notify("webhook.failed.rails_webhook_outbox", delivery, duration_ms)
|
|
43
|
+
record_failure_and_notify_if_tripped(delivery)
|
|
44
|
+
else
|
|
45
|
+
Rails.logger.warn { "[RailsWebhookOutbox] retry event=#{delivery.event} delivery_id=#{delivery.id} subscription_id=#{delivery.subscription_id} status=#{e.response_code} attempt=#{delivery.attempts} next_retry_at=#{delivery.next_retry_at.utc.iso8601}" }
|
|
46
|
+
raise
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def skip_disabled_subscription(delivery)
|
|
53
|
+
delivery.update!(status: :failed, next_retry_at: nil)
|
|
54
|
+
Rails.logger.warn { "[RailsWebhookOutbox] skipped event=#{delivery.event} delivery_id=#{delivery.id} subscription_id=#{delivery.subscription_id} reason=subscription_inactive" }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def elapsed_ms(start)
|
|
58
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def notify(event_name, delivery, duration_ms)
|
|
62
|
+
ActiveSupport::Notifications.instrument(event_name,
|
|
63
|
+
event: delivery.event,
|
|
64
|
+
subscription_id: delivery.subscription_id,
|
|
65
|
+
delivery_id: delivery.id,
|
|
66
|
+
duration: duration_ms)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def record_failure_and_notify_if_tripped(delivery)
|
|
70
|
+
return unless delivery.subscription.record_delivery_failure!
|
|
71
|
+
|
|
72
|
+
subscription = delivery.subscription
|
|
73
|
+
Rails.logger.warn { "[RailsWebhookOutbox] circuit_breaker_tripped subscription_id=#{subscription.id} consecutive_failures=#{subscription.consecutive_failures}" }
|
|
74
|
+
ActiveSupport::Notifications.instrument("webhook.circuit_breaker_tripped.rails_webhook_outbox",
|
|
75
|
+
subscription_id: subscription.id,
|
|
76
|
+
consecutive_failures: subscription.consecutive_failures)
|
|
24
77
|
end
|
|
25
78
|
end
|
|
26
79
|
end
|
|
@@ -6,9 +6,18 @@ module RailsWebhookOutbox
|
|
|
6
6
|
|
|
7
7
|
enum :status, { pending: 0, delivered: 1, failed: 2 }
|
|
8
8
|
|
|
9
|
+
before_validation :generate_idempotency_key, on: :create
|
|
10
|
+
|
|
9
11
|
validates :event, presence: true
|
|
10
12
|
validates :payload, presence: true
|
|
13
|
+
validates :idempotency_key, presence: true
|
|
11
14
|
|
|
12
15
|
scope :retryable, -> { pending }
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def generate_idempotency_key
|
|
20
|
+
self.idempotency_key ||= SecureRandom.uuid
|
|
21
|
+
end
|
|
13
22
|
end
|
|
14
23
|
end
|
|
@@ -7,6 +7,7 @@ module RailsWebhookOutbox
|
|
|
7
7
|
attribute :active, :boolean, default: true
|
|
8
8
|
|
|
9
9
|
before_validation :generate_secret, on: :create
|
|
10
|
+
before_save :reset_consecutive_failures_on_reactivation
|
|
10
11
|
|
|
11
12
|
validates :url, presence: true, format: { with: URL_FORMAT, message: "must be a valid HTTP or HTTPS URL" }
|
|
12
13
|
validates :secret, presence: true
|
|
@@ -18,10 +19,52 @@ module RailsWebhookOutbox
|
|
|
18
19
|
events.include?(event.to_s)
|
|
19
20
|
end
|
|
20
21
|
|
|
22
|
+
def rotate_secret!(grace_period: RailsWebhookOutbox.config.secret_rotation_grace_period, force: false)
|
|
23
|
+
raise SecretRotationError if previous_secret_active? && !force
|
|
24
|
+
|
|
25
|
+
update!(
|
|
26
|
+
previous_secret: secret,
|
|
27
|
+
previous_secret_expires_at: Time.current + grace_period,
|
|
28
|
+
secret: generate_secret_value
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def previous_secret_active?
|
|
33
|
+
previous_secret.present? && previous_secret_expires_at.present? && previous_secret_expires_at.future?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def signing_secrets
|
|
37
|
+
previous_secret_active? ? [secret, previous_secret] : [secret]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def record_delivery_success!
|
|
41
|
+
update!(consecutive_failures: 0) if consecutive_failures != 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def record_delivery_failure!(threshold: RailsWebhookOutbox.config.circuit_breaker_threshold)
|
|
45
|
+
with_lock do
|
|
46
|
+
next false unless active?
|
|
47
|
+
|
|
48
|
+
increment!(:consecutive_failures)
|
|
49
|
+
next false unless threshold&.positive? && consecutive_failures >= threshold
|
|
50
|
+
|
|
51
|
+
update!(active: false)
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
21
56
|
private
|
|
22
57
|
|
|
23
58
|
def generate_secret
|
|
24
|
-
self.secret ||=
|
|
59
|
+
self.secret ||= generate_secret_value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def generate_secret_value
|
|
63
|
+
SecureRandom.hex(32)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def reset_consecutive_failures_on_reactivation
|
|
67
|
+
self.consecutive_failures = 0 if active_changed?(from: false, to: true)
|
|
25
68
|
end
|
|
26
69
|
end
|
|
27
70
|
end
|
|
@@ -16,11 +16,11 @@ module RailsWebhookOutbox
|
|
|
16
16
|
|
|
17
17
|
def copy_migrations
|
|
18
18
|
migration_template(
|
|
19
|
-
"create_webhook_outbox_subscriptions.rb",
|
|
19
|
+
"create_webhook_outbox_subscriptions.rb.tt",
|
|
20
20
|
"db/migrate/create_webhook_outbox_subscriptions.rb"
|
|
21
21
|
)
|
|
22
22
|
migration_template(
|
|
23
|
-
"create_webhook_outbox_deliveries.rb",
|
|
23
|
+
"create_webhook_outbox_deliveries.rb.tt",
|
|
24
24
|
"db/migrate/create_webhook_outbox_deliveries.rb"
|
|
25
25
|
)
|
|
26
26
|
end
|
|
@@ -8,6 +8,7 @@ class CreateWebhookOutboxDeliveries < ActiveRecord::Migration[<%= ActiveRecord::
|
|
|
8
8
|
t.integer :response_code
|
|
9
9
|
t.text :response_body
|
|
10
10
|
t.integer :attempts, null: false, default: 0
|
|
11
|
+
t.string :idempotency_key
|
|
11
12
|
t.datetime :delivered_at
|
|
12
13
|
t.datetime :next_retry_at
|
|
13
14
|
t.timestamps
|
|
@@ -17,4 +18,4 @@ class CreateWebhookOutboxDeliveries < ActiveRecord::Migration[<%= ActiveRecord::
|
|
|
17
18
|
add_index :webhook_outbox_deliveries, :event
|
|
18
19
|
add_index :webhook_outbox_deliveries, [:subscription_id, :created_at]
|
|
19
20
|
end
|
|
20
|
-
end
|
|
21
|
+
end
|
|
@@ -3,8 +3,11 @@ class CreateWebhookOutboxSubscriptions < ActiveRecord::Migration[<%= ActiveRecor
|
|
|
3
3
|
create_table :webhook_outbox_subscriptions do |t|
|
|
4
4
|
t.string :url, null: false
|
|
5
5
|
t.string :secret, null: false
|
|
6
|
+
t.string :previous_secret
|
|
7
|
+
t.datetime :previous_secret_expires_at
|
|
6
8
|
t.json :events, null: false, default: []
|
|
7
9
|
t.boolean :active, null: false, default: true
|
|
10
|
+
t.integer :consecutive_failures, null: false, default: 0
|
|
8
11
|
t.string :description
|
|
9
12
|
t.json :metadata, default: {}
|
|
10
13
|
t.timestamps
|
|
@@ -19,4 +19,12 @@ RailsWebhookOutbox.configure do |config|
|
|
|
19
19
|
|
|
20
20
|
# ActiveJob queue for delivery jobs.
|
|
21
21
|
config.delivery_job_queue = :webhooks
|
|
22
|
+
|
|
23
|
+
# Maximum payload size in bytes. Raises PayloadSizeError before enqueuing if exceeded.
|
|
24
|
+
# Set to nil or 0 to disable.
|
|
25
|
+
config.max_payload_size = 65_536
|
|
26
|
+
|
|
27
|
+
# Set to true in test environments to suppress HTTP calls and capture dispatched events
|
|
28
|
+
# in RailsWebhookOutbox::Testing.deliveries instead of creating DB records or jobs.
|
|
29
|
+
# config.test_mode = false
|
|
22
30
|
end
|
|
@@ -5,7 +5,8 @@ module RailsWebhookOutbox
|
|
|
5
5
|
|
|
6
6
|
attr_accessor :events, :signing_algorithm, :signing_header,
|
|
7
7
|
:max_retries, :retry_backoff, :request_timeout,
|
|
8
|
-
:delivery_job_queue
|
|
8
|
+
:delivery_job_queue, :max_payload_size, :test_mode,
|
|
9
|
+
:secret_rotation_grace_period, :circuit_breaker_threshold
|
|
9
10
|
|
|
10
11
|
def initialize
|
|
11
12
|
@events = []
|
|
@@ -15,6 +16,10 @@ module RailsWebhookOutbox
|
|
|
15
16
|
@retry_backoff = :exponential
|
|
16
17
|
@request_timeout = 5
|
|
17
18
|
@delivery_job_queue = :webhooks
|
|
19
|
+
@max_payload_size = 65_536
|
|
20
|
+
@test_mode = false
|
|
21
|
+
@secret_rotation_grace_period = 24.hours
|
|
22
|
+
@circuit_breaker_threshold = 10
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
def signing_algorithm=(value)
|
|
@@ -34,5 +39,13 @@ module RailsWebhookOutbox
|
|
|
34
39
|
|
|
35
40
|
@retry_backoff = value
|
|
36
41
|
end
|
|
42
|
+
|
|
43
|
+
def secret_rotation_grace_period=(value)
|
|
44
|
+
unless value.is_a?(Numeric) && value.positive?
|
|
45
|
+
raise ArgumentError, "secret_rotation_grace_period must be a positive duration"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@secret_rotation_grace_period = value
|
|
49
|
+
end
|
|
37
50
|
end
|
|
38
51
|
end
|
|
@@ -24,7 +24,15 @@ module RailsWebhookOutbox
|
|
|
24
24
|
def _dispatch_webhook(event, condition)
|
|
25
25
|
return if condition && !instance_exec(&condition)
|
|
26
26
|
|
|
27
|
+
RailsWebhookOutbox.validate_event!(event)
|
|
28
|
+
|
|
27
29
|
payload = webhook_payload
|
|
30
|
+
RailsWebhookOutbox.validate_payload_size!(payload)
|
|
31
|
+
|
|
32
|
+
if RailsWebhookOutbox.config.test_mode
|
|
33
|
+
RailsWebhookOutbox::Testing.deliveries << { event: event.to_s, payload: payload }
|
|
34
|
+
return
|
|
35
|
+
end
|
|
28
36
|
|
|
29
37
|
Subscription.active.each do |subscription|
|
|
30
38
|
next unless subscription.subscribes_to?(event)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "rails_webhook_outbox/testing"
|
|
2
|
+
|
|
3
|
+
RSpec::Matchers.define :dispatch_webhook do |expected_event|
|
|
4
|
+
chain :with_payload do |payload|
|
|
5
|
+
@expected_payload = payload
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
match do |block|
|
|
9
|
+
before = RailsWebhookOutbox::Testing.deliveries.dup
|
|
10
|
+
block.call
|
|
11
|
+
@dispatched = RailsWebhookOutbox::Testing.deliveries.drop(before.size)
|
|
12
|
+
@dispatched.any? do |d|
|
|
13
|
+
d[:event] == expected_event.to_s &&
|
|
14
|
+
(@expected_payload.nil? || d[:payload] == @expected_payload)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
supports_block_expectations
|
|
19
|
+
|
|
20
|
+
failure_message do
|
|
21
|
+
if @dispatched.empty?
|
|
22
|
+
"expected #{expected_event.inspect} webhook to be dispatched, but no webhooks were dispatched"
|
|
23
|
+
else
|
|
24
|
+
events = @dispatched.map { |d| d[:event].inspect }.join(", ")
|
|
25
|
+
"expected #{expected_event.inspect} webhook to be dispatched, but dispatched: #{events}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
failure_message_when_negated do
|
|
30
|
+
"expected #{expected_event.inspect} webhook not to be dispatched, but it was"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -17,6 +17,7 @@ module RailsWebhookOutbox
|
|
|
17
17
|
uri = URI.parse(@delivery.subscription.url)
|
|
18
18
|
body = build_body
|
|
19
19
|
request = build_request(uri, body)
|
|
20
|
+
Rails.logger.info { "[RailsWebhookOutbox] attempt event=#{@delivery.event} key=#{@delivery.idempotency_key} url=#{uri}" }
|
|
20
21
|
response = execute(uri, request)
|
|
21
22
|
raise DeliveryError.new(response) unless response.is_a?(Net::HTTPSuccess)
|
|
22
23
|
response
|
|
@@ -35,9 +36,9 @@ module RailsWebhookOutbox
|
|
|
35
36
|
def build_request(uri, body)
|
|
36
37
|
req = Net::HTTP::Post.new(uri)
|
|
37
38
|
req["Content-Type"] = "application/json"
|
|
38
|
-
req["X-Webhook-Signature"] = Signature.header_value(body, @delivery.subscription.
|
|
39
|
+
req["X-Webhook-Signature"] = Signature.header_value(body, @delivery.subscription.signing_secrets)
|
|
39
40
|
req["X-Webhook-Event"] = @delivery.event
|
|
40
|
-
req["X-Webhook-Delivery"] =
|
|
41
|
+
req["X-Webhook-Delivery"] = @delivery.idempotency_key
|
|
41
42
|
req["X-Webhook-Timestamp"] = Time.now.utc.to_i.to_s
|
|
42
43
|
req.body = body
|
|
43
44
|
req
|
|
@@ -6,9 +6,9 @@ module RailsWebhookOutbox
|
|
|
6
6
|
OpenSSL::HMAC.hexdigest(algorithm.to_s.upcase, secret, payload)
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
-
def self.header_value(payload,
|
|
9
|
+
def self.header_value(payload, secrets)
|
|
10
10
|
algorithm = RailsWebhookOutbox.config.signing_algorithm
|
|
11
|
-
"#{algorithm}=#{sign(payload, secret, algorithm)}"
|
|
11
|
+
Array(secrets).map { |secret| "#{algorithm}=#{sign(payload, secret, algorithm)}" }.join(",")
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
end
|
data/lib/rails_webhook_outbox.rb
CHANGED
|
@@ -2,6 +2,9 @@ require "rails_webhook_outbox/version"
|
|
|
2
2
|
require "rails_webhook_outbox/configuration"
|
|
3
3
|
require "rails_webhook_outbox/signature"
|
|
4
4
|
require "rails_webhook_outbox/delivery_error"
|
|
5
|
+
require "rails_webhook_outbox/payload_size_error"
|
|
6
|
+
require "rails_webhook_outbox/secret_rotation_error"
|
|
7
|
+
require "rails_webhook_outbox/testing"
|
|
5
8
|
require "rails_webhook_outbox/sender"
|
|
6
9
|
require "rails_webhook_outbox/dispatchable"
|
|
7
10
|
require "rails_webhook_outbox/engine"
|
|
@@ -22,7 +25,31 @@ module RailsWebhookOutbox
|
|
|
22
25
|
@configuration = Configuration.new
|
|
23
26
|
end
|
|
24
27
|
|
|
28
|
+
def validate_event!(event)
|
|
29
|
+
registered = config.events
|
|
30
|
+
return if registered.empty?
|
|
31
|
+
return if registered.include?(event.to_s)
|
|
32
|
+
|
|
33
|
+
raise ArgumentError, "Unknown event #{event.inspect}. Registered events: #{registered.join(", ")}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def validate_payload_size!(payload)
|
|
37
|
+
max = config.max_payload_size
|
|
38
|
+
return unless max && max > 0
|
|
39
|
+
|
|
40
|
+
size = JSON.generate(payload).bytesize
|
|
41
|
+
raise PayloadSizeError.new(size, max) if size > max
|
|
42
|
+
end
|
|
43
|
+
|
|
25
44
|
def dispatch(event, payload)
|
|
45
|
+
validate_event!(event)
|
|
46
|
+
validate_payload_size!(payload)
|
|
47
|
+
|
|
48
|
+
if config.test_mode
|
|
49
|
+
Testing.deliveries << { event: event.to_s, payload: payload }
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
26
53
|
Subscription.active.each do |subscription|
|
|
27
54
|
next unless subscription.subscribes_to?(event)
|
|
28
55
|
|
|
@@ -1,4 +1,51 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
namespace :webhook_outbox do
|
|
2
|
+
desc "Re-enqueue failed deliveries for retry"
|
|
3
|
+
task retry_failed: :environment do
|
|
4
|
+
deliveries = RailsWebhookOutbox::Delivery.failed
|
|
5
|
+
count = deliveries.count
|
|
6
|
+
|
|
7
|
+
if count.zero?
|
|
8
|
+
puts "No failed deliveries to retry"
|
|
9
|
+
else
|
|
10
|
+
deliveries.find_each do |delivery|
|
|
11
|
+
delivery.update!(status: :pending, next_retry_at: nil)
|
|
12
|
+
RailsWebhookOutbox::DeliveryJob.perform_later(delivery)
|
|
13
|
+
end
|
|
14
|
+
puts "Re-enqueued #{count} failed #{"delivery".pluralize(count)} for retry"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc "List all webhook subscriptions"
|
|
19
|
+
task list_subscriptions: :environment do
|
|
20
|
+
subscriptions = RailsWebhookOutbox::Subscription.order(:id)
|
|
21
|
+
|
|
22
|
+
if subscriptions.none?
|
|
23
|
+
puts "No subscriptions found"
|
|
24
|
+
else
|
|
25
|
+
subscriptions.find_each do |subscription|
|
|
26
|
+
puts format(
|
|
27
|
+
"#%d [%s] %s events=%s failures=%d",
|
|
28
|
+
subscription.id,
|
|
29
|
+
subscription.active? ? "active" : "inactive",
|
|
30
|
+
subscription.url,
|
|
31
|
+
subscription.events.join(","),
|
|
32
|
+
subscription.consecutive_failures
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
desc "Delete delivered and failed deliveries older than the given number of days"
|
|
39
|
+
task :cleanup, [:days] => :environment do |_task, args|
|
|
40
|
+
days = args[:days].to_i
|
|
41
|
+
|
|
42
|
+
if days <= 0
|
|
43
|
+
puts "Usage: rake webhook_outbox:cleanup[days] (days must be a positive integer)"
|
|
44
|
+
else
|
|
45
|
+
scope = RailsWebhookOutbox::Delivery.where(status: [:delivered, :failed]).where(created_at: ...days.days.ago)
|
|
46
|
+
count = scope.count
|
|
47
|
+
scope.delete_all
|
|
48
|
+
puts "Deleted #{count} #{"delivery".pluralize(count)} older than #{days} #{"day".pluralize(days)}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_webhook_outbox
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chuck Smith
|
|
@@ -44,17 +44,24 @@ files:
|
|
|
44
44
|
- config/routes.rb
|
|
45
45
|
- db/migrate/20260624000001_create_webhook_outbox_subscriptions.rb
|
|
46
46
|
- db/migrate/20260624000002_create_webhook_outbox_deliveries.rb
|
|
47
|
+
- db/migrate/20260629000002_add_idempotency_key_to_webhook_outbox_deliveries.rb
|
|
48
|
+
- db/migrate/20260701000001_add_secret_rotation_to_webhook_outbox_subscriptions.rb
|
|
49
|
+
- db/migrate/20260701000002_add_consecutive_failures_to_webhook_outbox_subscriptions.rb
|
|
47
50
|
- lib/generators/rails_webhook_outbox/install/install_generator.rb
|
|
48
|
-
- lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_deliveries.rb
|
|
49
|
-
- lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb
|
|
51
|
+
- lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_deliveries.rb.tt
|
|
52
|
+
- lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb.tt
|
|
50
53
|
- lib/generators/rails_webhook_outbox/install/templates/initializer.rb
|
|
51
54
|
- lib/rails_webhook_outbox.rb
|
|
52
55
|
- lib/rails_webhook_outbox/configuration.rb
|
|
53
56
|
- lib/rails_webhook_outbox/delivery_error.rb
|
|
54
57
|
- lib/rails_webhook_outbox/dispatchable.rb
|
|
55
58
|
- lib/rails_webhook_outbox/engine.rb
|
|
59
|
+
- lib/rails_webhook_outbox/payload_size_error.rb
|
|
60
|
+
- lib/rails_webhook_outbox/rspec_matchers.rb
|
|
61
|
+
- lib/rails_webhook_outbox/secret_rotation_error.rb
|
|
56
62
|
- lib/rails_webhook_outbox/sender.rb
|
|
57
63
|
- lib/rails_webhook_outbox/signature.rb
|
|
64
|
+
- lib/rails_webhook_outbox/testing.rb
|
|
58
65
|
- lib/rails_webhook_outbox/version.rb
|
|
59
66
|
- lib/tasks/rails_webhook_outbox_tasks.rake
|
|
60
67
|
homepage: https://github.com/eclectic-coding/rails_webhook_outbox
|