rails_webhook_outbox 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.
Files changed (28) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +349 -0
  4. data/Rakefile +16 -0
  5. data/app/controllers/rails_webhook_outbox/application_controller.rb +4 -0
  6. data/app/jobs/rails_webhook_outbox/application_job.rb +4 -0
  7. data/app/jobs/rails_webhook_outbox/delivery_job.rb +26 -0
  8. data/app/mailers/rails_webhook_outbox/application_mailer.rb +6 -0
  9. data/app/models/rails_webhook_outbox/application_record.rb +5 -0
  10. data/app/models/rails_webhook_outbox/delivery.rb +14 -0
  11. data/app/models/rails_webhook_outbox/subscription.rb +27 -0
  12. data/config/routes.rb +2 -0
  13. data/db/migrate/20260624000001_create_webhook_outbox_subscriptions.rb +13 -0
  14. data/db/migrate/20260624000002_create_webhook_outbox_deliveries.rb +20 -0
  15. data/lib/generators/rails_webhook_outbox/install/install_generator.rb +33 -0
  16. data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_deliveries.rb +20 -0
  17. data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb +13 -0
  18. data/lib/generators/rails_webhook_outbox/install/templates/initializer.rb +22 -0
  19. data/lib/rails_webhook_outbox/configuration.rb +38 -0
  20. data/lib/rails_webhook_outbox/delivery_error.rb +11 -0
  21. data/lib/rails_webhook_outbox/dispatchable.rb +42 -0
  22. data/lib/rails_webhook_outbox/engine.rb +14 -0
  23. data/lib/rails_webhook_outbox/sender.rb +58 -0
  24. data/lib/rails_webhook_outbox/signature.rb +14 -0
  25. data/lib/rails_webhook_outbox/version.rb +3 -0
  26. data/lib/rails_webhook_outbox.rb +39 -0
  27. data/lib/tasks/rails_webhook_outbox_tasks.rake +4 -0
  28. metadata +85 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 743d24ea18d5fad259efd9369fc228866eeb883e6f5911d96d081e304a62aca4
4
+ data.tar.gz: 9bd087f6a44e6eedb95b1ccd97fd9e52423c547f67f3d4d384eb5b407fdfee1e
5
+ SHA512:
6
+ metadata.gz: 02b57c5bfb462a475dde3036a6ee5f18fe033faaaa808d03d1bd45417f687162210ad6987aee0135775a5d2298fa989d65ac24c06762201cffcad05bb66938f8
7
+ data.tar.gz: 5fdaac46aa8ad0591e5c0837df73d9d6bd10a9af15f02f96415ac45ced02e85608198bc8beed2baaa42449e93986ac3a816b9da0e66ae8ff5ff636c2c6807a1a
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Chuck Smith
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,349 @@
1
+ # RailsWebhookOutbox
2
+
3
+ [![CI](https://github.com/eclectic-coding/rails_webhook_outbox/actions/workflows/main.yml/badge.svg)](https://github.com/eclectic-coding/rails_webhook_outbox/actions/workflows/main.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/rails_webhook_outbox)](https://rubygems.org/gems/rails_webhook_outbox)
5
+ [![Gem Downloads](https://img.shields.io/gem/dt/rails_webhook_outbox)](https://rubygems.org/gems/rails_webhook_outbox)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-red)](https://www.ruby-lang.org)
7
+ [![Rails](https://img.shields.io/badge/rails-%3E%3D%207.2-red)](https://rubyonrails.org)
8
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
9
+ [![Codecov](https://codecov.io/gh/eclectic-coding/rails_webhook_outbox/graph/badge.svg)](https://codecov.io/gh/eclectic-coding/rails_webhook_outbox)
10
+
11
+ A Rails engine for sending outgoing webhooks with HMAC signing, ActiveJob-based retry, and delivery logging.
12
+
13
+ ## Table of Contents
14
+
15
+ - [Installation](#installation)
16
+ - [Configuration](#configuration)
17
+ - [Subscriptions](#subscriptions)
18
+ - [Deliveries](#deliveries)
19
+ - [Async Delivery](#async-delivery)
20
+ - [HTTP Request Format](#http-request-format)
21
+ - [HMAC Signing](#hmac-signing)
22
+ - [Usage](#usage)
23
+ - [Manual Dispatch](#manual-dispatch)
24
+ - [Development](#development)
25
+ - [Dummy App](#dummy-app)
26
+ - [Contributing](#contributing)
27
+ - [License](#license)
28
+
29
+ ## Installation
30
+
31
+ Add this line to your application's Gemfile:
32
+
33
+ ```ruby
34
+ gem "rails_webhook_outbox"
35
+ ```
36
+
37
+ And then execute:
38
+
39
+ ```bash
40
+ $ bundle install
41
+ ```
42
+
43
+ Run the install generator:
44
+
45
+ ```bash
46
+ $ rails generate rails_webhook_outbox:install
47
+ $ rails db:migrate
48
+ ```
49
+
50
+ ## Configuration
51
+
52
+ ```ruby
53
+ # config/initializers/rails_webhook_outbox.rb
54
+ RailsWebhookOutbox.configure do |config|
55
+ config.events = %w[
56
+ order.created
57
+ order.updated
58
+ user.signed_up
59
+ ]
60
+
61
+ config.signing_algorithm = :sha256
62
+ config.signing_header = "X-Webhook-Signature"
63
+ config.max_retries = 8
64
+ config.retry_backoff = :exponential
65
+ config.request_timeout = 5
66
+ config.delivery_job_queue = :webhooks
67
+ end
68
+ ```
69
+
70
+ [Back to top](#table-of-contents)
71
+
72
+ ## Subscriptions
73
+
74
+ A `RailsWebhookOutbox::Subscription` represents an endpoint that receives webhook events.
75
+
76
+ ```ruby
77
+ sub = RailsWebhookOutbox::Subscription.create!(
78
+ url: "https://example.com/webhooks",
79
+ events: ["order.created", "order.updated"]
80
+ )
81
+
82
+ sub.secret # => "a3f9..." (auto-generated 64-char hex string)
83
+ sub.active? # => true (default)
84
+ sub.subscribes_to?("order.created") # => true
85
+ sub.subscribes_to?("payment.failed") # => false
86
+ ```
87
+
88
+ Use the `active` scope to find enabled subscriptions:
89
+
90
+ ```ruby
91
+ RailsWebhookOutbox::Subscription.active
92
+ ```
93
+
94
+ Disable a subscription by setting `active: false`:
95
+
96
+ ```ruby
97
+ sub.update!(active: false)
98
+ ```
99
+
100
+ [Back to top](#table-of-contents)
101
+
102
+ ## Deliveries
103
+
104
+ A `RailsWebhookOutbox::Delivery` records each attempt to send a webhook event to a subscription endpoint.
105
+
106
+ ```ruby
107
+ delivery = RailsWebhookOutbox::Delivery.create!(
108
+ subscription: subscription,
109
+ event: "order.created",
110
+ payload: { id: 42, total: "99.00" }
111
+ )
112
+
113
+ delivery.pending? # => true (default)
114
+ delivery.delivered!
115
+ delivery.delivered? # => true
116
+ ```
117
+
118
+ Filter deliveries by status:
119
+
120
+ ```ruby
121
+ RailsWebhookOutbox::Delivery.retryable # pending — awaiting delivery or retry
122
+ RailsWebhookOutbox::Delivery.delivered # successfully delivered
123
+ RailsWebhookOutbox::Delivery.failed # exhausted all retries
124
+ ```
125
+
126
+ [Back to top](#table-of-contents)
127
+
128
+ ## Async Delivery
129
+
130
+ `RailsWebhookOutbox::DeliveryJob` handles HTTP dispatch for each `Delivery` record. It is an `ActiveJob` subclass, so it works with any queue backend (Sidekiq, Solid Queue, GoodJob, etc.).
131
+
132
+ Configure the queue and retry behaviour in the initializer:
133
+
134
+ ```ruby
135
+ RailsWebhookOutbox.configure do |config|
136
+ config.delivery_job_queue = :webhooks # default
137
+ config.max_retries = 8 # default
138
+ end
139
+ ```
140
+
141
+ The job uses polynomial backoff (`:polynomially_longer`) between retries — wait time grows with each attempt. On each failed attempt it updates the delivery record before re-raising so progress is always persisted:
142
+
143
+ | Execution | Outcome | Delivery status |
144
+ |-----------|---------|-----------------|
145
+ | 1–(n-1) | non-2xx response | `pending` — will retry |
146
+ | n (`max_retries`) | non-2xx response | `failed` — no further retry |
147
+ | any | 2xx response | `delivered` |
148
+
149
+ Every attempt (success or failure) increments `delivery.attempts` and stores the `response_code` and `response_body`. Successful deliveries also set `delivered_at`.
150
+
151
+ Enqueue a delivery manually:
152
+
153
+ ```ruby
154
+ RailsWebhookOutbox::DeliveryJob.perform_later(delivery)
155
+ ```
156
+
157
+ [Back to top](#table-of-contents)
158
+
159
+ ## HTTP Request Format
160
+
161
+ Each webhook delivery is an HTTP POST to the subscription URL with the following headers and body:
162
+
163
+ ```
164
+ POST https://example.com/webhooks
165
+ Content-Type: application/json
166
+ X-Webhook-Signature: sha256=a1b2c3d4...
167
+ X-Webhook-Event: order.created
168
+ X-Webhook-Delivery: 550e8400-e29b-41d4-a716-446655440000
169
+ X-Webhook-Timestamp: 1719100800
170
+
171
+ {
172
+ "event": "order.created",
173
+ "delivered_at": "2026-06-26T10:00:00Z",
174
+ "data": { "id": 42, "total": "99.00" }
175
+ }
176
+ ```
177
+
178
+ Non-2xx responses raise `RailsWebhookOutbox::DeliveryError`, which carries `response_code` and `response_body` for logging and retry decisions.
179
+
180
+ [Back to top](#table-of-contents)
181
+
182
+ ## HMAC Signing
183
+
184
+ Every outgoing request includes an `X-Webhook-Signature` header (configurable) containing an HMAC digest of the request body:
185
+
186
+ ```
187
+ X-Webhook-Signature: sha256=a1b2c3d4...
188
+ ```
189
+
190
+ Subscribers can verify the signature:
191
+
192
+ ```ruby
193
+ expected = RailsWebhookOutbox::Signature.header_value(raw_body, subscription.secret)
194
+ Rack::Utils.secure_compare(expected, request.headers["X-Webhook-Signature"])
195
+ ```
196
+
197
+ You can also call the primitives directly:
198
+
199
+ ```ruby
200
+ # Produce a hex digest with an explicit algorithm
201
+ RailsWebhookOutbox::Signature.sign(payload, secret, :sha256)
202
+ # => "a1b2c3d4..."
203
+
204
+ # Produce the full header value using the configured algorithm
205
+ RailsWebhookOutbox::Signature.header_value(payload, secret)
206
+ # => "sha256=a1b2c3d4..."
207
+ ```
208
+
209
+ [Back to top](#table-of-contents)
210
+
211
+ ## Usage
212
+
213
+ Include `RailsWebhookOutbox::Dispatchable` in any ActiveRecord model to automatically dispatch webhooks on lifecycle events:
214
+
215
+ ```ruby
216
+ class Order < ApplicationRecord
217
+ include RailsWebhookOutbox::Dispatchable
218
+
219
+ dispatches_webhook "order.created", on: :create
220
+ dispatches_webhook "order.updated", on: :update
221
+ dispatches_webhook "order.cancelled", on: :update,
222
+ if: -> { cancelled_at_previously_changed? }
223
+ end
224
+ ```
225
+
226
+ When a callback fires, the concern finds every active `Subscription` that includes that event, creates a `Delivery` record for each one, and enqueues a `DeliveryJob`. No other wiring is required.
227
+
228
+ The `if:` option accepts a lambda that is evaluated in the context of the model instance, so any attribute or method is available.
229
+
230
+ **Payload**
231
+
232
+ By default the full record is sent as the webhook payload via `as_json`. Override `webhook_payload` to control exactly what is sent:
233
+
234
+ ```ruby
235
+ class Order < ApplicationRecord
236
+ include RailsWebhookOutbox::Dispatchable
237
+
238
+ dispatches_webhook "order.created", on: :create
239
+
240
+ def webhook_payload
241
+ { id:, total: total.to_s, items: line_items.count }
242
+ end
243
+ end
244
+ ```
245
+
246
+ [Back to top](#table-of-contents)
247
+
248
+ ## Manual Dispatch
249
+
250
+ Dispatch a webhook event outside of model callbacks using `RailsWebhookOutbox.dispatch`:
251
+
252
+ ```ruby
253
+ RailsWebhookOutbox.dispatch("payment.completed", {
254
+ id: payment.id,
255
+ amount: payment.amount,
256
+ currency: payment.currency
257
+ })
258
+ ```
259
+
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.
261
+
262
+ This is the same delivery pipeline used by `Dispatchable` callbacks, so retries, HMAC signing, and delivery logging all apply.
263
+
264
+ [Back to top](#table-of-contents)
265
+
266
+ ## Development
267
+
268
+ ```bash
269
+ $ bundle install
270
+ $ bundle exec rspec
271
+ $ bin/rubocop
272
+ ```
273
+
274
+ ### Dummy App
275
+
276
+ The gem includes a full dummy Rails app at `spec/dummy/` for end-to-end testing in a running server. It has an `Order` model wired to `Dispatchable`, an `OrdersController`, and seed data.
277
+
278
+ **Setup**
279
+
280
+ From the repo root:
281
+
282
+ ```bash
283
+ $ cd spec/dummy
284
+ $ bin/setup # installs deps, runs db:prepare, then starts the server
285
+ ```
286
+
287
+ Or set up without starting the server:
288
+
289
+ ```bash
290
+ $ bin/rails db:create db:migrate
291
+ $ bin/rails db:schema:load:queue
292
+ $ bin/rails db:seed
293
+ ```
294
+
295
+ **Start the server**
296
+
297
+ `bin/dev` runs the web server and solid_queue worker together via foreman:
298
+
299
+ ```bash
300
+ $ bin/dev
301
+ ```
302
+
303
+ The API is available at `http://localhost:3000`.
304
+
305
+ **Try it with curl**
306
+
307
+ Create an order (fires `order.created`):
308
+
309
+ ```bash
310
+ curl -X POST http://localhost:3000/orders \
311
+ -H "Content-Type: application/json" \
312
+ -d '{"order": {"title": "Widget Pack", "total": "49.99", "status": "pending"}}'
313
+ ```
314
+
315
+ Update an order (fires `order.updated`):
316
+
317
+ ```bash
318
+ curl -X PATCH http://localhost:3000/orders/1 \
319
+ -H "Content-Type: application/json" \
320
+ -d '{"order": {"status": "confirmed"}}'
321
+ ```
322
+
323
+ Cancel an order (fires `order.cancelled`):
324
+
325
+ ```bash
326
+ curl -X PATCH http://localhost:3000/orders/1 \
327
+ -H "Content-Type: application/json" \
328
+ -d '{"order": {"status": "cancelled", "cancelled_at": "2026-06-29T12:00:00Z"}}'
329
+ ```
330
+
331
+ The seed data creates a subscription pointing to `http://localhost:4000/webhooks`. Point it at any local receiver (e.g. a `webhook.site` URL or a local listener) by updating the subscription record directly:
332
+
333
+ ```ruby
334
+ RailsWebhookOutbox::Subscription.first.update!(url: "https://webhook.site/your-id")
335
+ ```
336
+
337
+ [Back to top](#table-of-contents)
338
+
339
+ ## Contributing
340
+
341
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/eclectic-coding/rails_webhook_outbox).
342
+
343
+ [Back to top](#table-of-contents)
344
+
345
+ ## License
346
+
347
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
348
+
349
+ [Back to top](#table-of-contents)
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
7
+
8
+ require 'rubocop/rake_task'
9
+ require 'bundler/audit/task'
10
+ require 'rspec/core/rake_task'
11
+
12
+ RuboCop::RakeTask.new(:lint)
13
+ Bundler::Audit::Task.new
14
+ RSpec::Core::RakeTask.new(:spec)
15
+
16
+ task default: [:lint, :'bundle:audit:update', 'bundle:audit:check', :spec]
@@ -0,0 +1,4 @@
1
+ module RailsWebhookOutbox
2
+ class ApplicationController < ActionController::API
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module RailsWebhookOutbox
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,26 @@
1
+ module RailsWebhookOutbox
2
+ class DeliveryJob < ApplicationJob
3
+ queue_as { RailsWebhookOutbox.config.delivery_job_queue }
4
+ retry_on RailsWebhookOutbox::DeliveryError, wait: :polynomially_longer, attempts: :unlimited
5
+
6
+ def perform(delivery)
7
+ response = Sender.call(delivery)
8
+ delivery.update!(
9
+ status: :delivered,
10
+ response_code: response.code.to_i,
11
+ response_body: response.body.truncate(1000),
12
+ delivered_at: Time.current,
13
+ attempts: delivery.attempts + 1
14
+ )
15
+ rescue DeliveryError => e
16
+ max_retries = RailsWebhookOutbox.config.max_retries
17
+ delivery.update!(
18
+ response_code: e.response_code,
19
+ response_body: e.response_body&.truncate(1000),
20
+ attempts: delivery.attempts + 1,
21
+ status: executions >= max_retries ? :failed : :pending
22
+ )
23
+ raise if executions < max_retries
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,6 @@
1
+ module RailsWebhookOutbox
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module RailsWebhookOutbox
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,14 @@
1
+ module RailsWebhookOutbox
2
+ class Delivery < ApplicationRecord
3
+ self.table_name = "webhook_outbox_deliveries"
4
+
5
+ belongs_to :subscription
6
+
7
+ enum :status, { pending: 0, delivered: 1, failed: 2 }
8
+
9
+ validates :event, presence: true
10
+ validates :payload, presence: true
11
+
12
+ scope :retryable, -> { pending }
13
+ end
14
+ end
@@ -0,0 +1,27 @@
1
+ module RailsWebhookOutbox
2
+ class Subscription < ApplicationRecord
3
+ self.table_name = "webhook_outbox_subscriptions"
4
+
5
+ URL_FORMAT = /\Ahttps?:\/\/.+/i
6
+
7
+ attribute :active, :boolean, default: true
8
+
9
+ before_validation :generate_secret, on: :create
10
+
11
+ validates :url, presence: true, format: { with: URL_FORMAT, message: "must be a valid HTTP or HTTPS URL" }
12
+ validates :secret, presence: true
13
+ validates :events, presence: true
14
+
15
+ scope :active, -> { where(active: true) }
16
+
17
+ def subscribes_to?(event)
18
+ events.include?(event.to_s)
19
+ end
20
+
21
+ private
22
+
23
+ def generate_secret
24
+ self.secret ||= SecureRandom.hex(32)
25
+ end
26
+ end
27
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ RailsWebhookOutbox::Engine.routes.draw do
2
+ end
@@ -0,0 +1,13 @@
1
+ class CreateWebhookOutboxSubscriptions < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :webhook_outbox_subscriptions do |t|
4
+ t.string :url, null: false
5
+ t.string :secret, null: false
6
+ t.json :events, null: false, default: []
7
+ t.boolean :active, null: false, default: true
8
+ t.string :description
9
+ t.json :metadata, default: {}
10
+ t.timestamps
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,20 @@
1
+ class CreateWebhookOutboxDeliveries < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :webhook_outbox_deliveries do |t|
4
+ t.references :subscription, null: false, foreign_key: { to_table: :webhook_outbox_subscriptions }
5
+ t.string :event, null: false
6
+ t.json :payload, null: false
7
+ t.integer :status, null: false, default: 0
8
+ t.integer :response_code
9
+ t.text :response_body
10
+ t.integer :attempts, null: false, default: 0
11
+ t.datetime :delivered_at
12
+ t.datetime :next_retry_at
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :webhook_outbox_deliveries, :status
17
+ add_index :webhook_outbox_deliveries, :event
18
+ add_index :webhook_outbox_deliveries, [:subscription_id, :created_at]
19
+ end
20
+ end
@@ -0,0 +1,33 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module RailsWebhookOutbox
5
+ module Generators
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Creates a RailsWebhookOutbox initializer and copies migrations to your application."
12
+
13
+ def self.next_migration_number(dirname)
14
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
15
+ end
16
+
17
+ def copy_migrations
18
+ migration_template(
19
+ "create_webhook_outbox_subscriptions.rb",
20
+ "db/migrate/create_webhook_outbox_subscriptions.rb"
21
+ )
22
+ migration_template(
23
+ "create_webhook_outbox_deliveries.rb",
24
+ "db/migrate/create_webhook_outbox_deliveries.rb"
25
+ )
26
+ end
27
+
28
+ def create_initializer
29
+ template "initializer.rb", "config/initializers/rails_webhook_outbox.rb"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,20 @@
1
+ class CreateWebhookOutboxDeliveries < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :webhook_outbox_deliveries do |t|
4
+ t.references :subscription, null: false, foreign_key: { to_table: :webhook_outbox_subscriptions }
5
+ t.string :event, null: false
6
+ t.json :payload, null: false
7
+ t.integer :status, null: false, default: 0
8
+ t.integer :response_code
9
+ t.text :response_body
10
+ t.integer :attempts, null: false, default: 0
11
+ t.datetime :delivered_at
12
+ t.datetime :next_retry_at
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :webhook_outbox_deliveries, :status
17
+ add_index :webhook_outbox_deliveries, :event
18
+ add_index :webhook_outbox_deliveries, [:subscription_id, :created_at]
19
+ end
20
+ end
@@ -0,0 +1,13 @@
1
+ class CreateWebhookOutboxSubscriptions < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :webhook_outbox_subscriptions do |t|
4
+ t.string :url, null: false
5
+ t.string :secret, null: false
6
+ t.json :events, null: false, default: []
7
+ t.boolean :active, null: false, default: true
8
+ t.string :description
9
+ t.json :metadata, default: {}
10
+ t.timestamps
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,22 @@
1
+ RailsWebhookOutbox.configure do |config|
2
+ # Events that subscribers can register for.
3
+ config.events = %w[order.created order.updated]
4
+
5
+ # HMAC algorithm for signing payloads. Options: :sha256, :sha384, :sha512
6
+ config.signing_algorithm = :sha256
7
+
8
+ # HTTP header that carries the HMAC signature.
9
+ config.signing_header = "X-Webhook-Signature"
10
+
11
+ # Maximum delivery attempts before a delivery is marked failed.
12
+ config.max_retries = 8
13
+
14
+ # Retry delay strategy. Options: :exponential, :linear
15
+ config.retry_backoff = :exponential
16
+
17
+ # HTTP timeout in seconds for each delivery attempt.
18
+ config.request_timeout = 5
19
+
20
+ # ActiveJob queue for delivery jobs.
21
+ config.delivery_job_queue = :webhooks
22
+ end
@@ -0,0 +1,38 @@
1
+ module RailsWebhookOutbox
2
+ class Configuration
3
+ SIGNING_ALGORITHMS = %i[sha256 sha384 sha512].freeze
4
+ RETRY_BACKOFF_STRATEGIES = %i[exponential linear].freeze
5
+
6
+ attr_accessor :events, :signing_algorithm, :signing_header,
7
+ :max_retries, :retry_backoff, :request_timeout,
8
+ :delivery_job_queue
9
+
10
+ def initialize
11
+ @events = []
12
+ @signing_algorithm = :sha256
13
+ @signing_header = "X-Webhook-Signature"
14
+ @max_retries = 8
15
+ @retry_backoff = :exponential
16
+ @request_timeout = 5
17
+ @delivery_job_queue = :webhooks
18
+ end
19
+
20
+ def signing_algorithm=(value)
21
+ value = value.to_sym
22
+ unless SIGNING_ALGORITHMS.include?(value)
23
+ raise ArgumentError, "Unknown signing algorithm: #{value}. Must be one of: #{SIGNING_ALGORITHMS.join(", ")}"
24
+ end
25
+
26
+ @signing_algorithm = value
27
+ end
28
+
29
+ def retry_backoff=(value)
30
+ value = value.to_sym
31
+ unless RETRY_BACKOFF_STRATEGIES.include?(value)
32
+ raise ArgumentError, "Unknown retry backoff strategy: #{value}. Must be one of: #{RETRY_BACKOFF_STRATEGIES.join(", ")}"
33
+ end
34
+
35
+ @retry_backoff = value
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,11 @@
1
+ module RailsWebhookOutbox
2
+ class DeliveryError < StandardError
3
+ attr_reader :response_code, :response_body
4
+
5
+ def initialize(response)
6
+ @response_code = response.code.to_i
7
+ @response_body = response.body
8
+ super("HTTP #{@response_code}")
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,42 @@
1
+ module RailsWebhookOutbox
2
+ module Dispatchable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class_attribute :_webhook_dispatches, instance_writer: false
7
+ self._webhook_dispatches = []
8
+ end
9
+
10
+ class_methods do
11
+ def dispatches_webhook(event, on:, **options)
12
+ condition = options[:if]
13
+ self._webhook_dispatches = _webhook_dispatches + [{ event: event, on: on, condition: condition }]
14
+ send(:"after_#{on}", -> { _dispatch_webhook(event, condition) })
15
+ end
16
+ end
17
+
18
+ def webhook_payload
19
+ as_json
20
+ end
21
+
22
+ private
23
+
24
+ def _dispatch_webhook(event, condition)
25
+ return if condition && !instance_exec(&condition)
26
+
27
+ payload = webhook_payload
28
+
29
+ Subscription.active.each do |subscription|
30
+ next unless subscription.subscribes_to?(event)
31
+
32
+ delivery = Delivery.create!(
33
+ subscription: subscription,
34
+ event: event,
35
+ payload: payload
36
+ )
37
+
38
+ DeliveryJob.perform_later(delivery)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,14 @@
1
+ module RailsWebhookOutbox
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace RailsWebhookOutbox
4
+ config.generators.api_only = true
5
+
6
+ initializer :append_migrations do |app|
7
+ unless app.root.to_s.match?(__dir__)
8
+ RailsWebhookOutbox::Engine.config.paths["db/migrate"].expanded.each do |path|
9
+ app.config.paths["db/migrate"] << path
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,58 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module RailsWebhookOutbox
7
+ class Sender
8
+ def self.call(delivery)
9
+ new(delivery).call
10
+ end
11
+
12
+ def initialize(delivery)
13
+ @delivery = delivery
14
+ end
15
+
16
+ def call
17
+ uri = URI.parse(@delivery.subscription.url)
18
+ body = build_body
19
+ request = build_request(uri, body)
20
+ response = execute(uri, request)
21
+ raise DeliveryError.new(response) unless response.is_a?(Net::HTTPSuccess)
22
+ response
23
+ end
24
+
25
+ private
26
+
27
+ def build_body
28
+ JSON.generate(
29
+ event: @delivery.event,
30
+ delivered_at: Time.now.utc.iso8601,
31
+ data: @delivery.payload
32
+ )
33
+ end
34
+
35
+ def build_request(uri, body)
36
+ req = Net::HTTP::Post.new(uri)
37
+ req["Content-Type"] = "application/json"
38
+ req["X-Webhook-Signature"] = Signature.header_value(body, @delivery.subscription.secret)
39
+ req["X-Webhook-Event"] = @delivery.event
40
+ req["X-Webhook-Delivery"] = SecureRandom.uuid
41
+ req["X-Webhook-Timestamp"] = Time.now.utc.to_i.to_s
42
+ req.body = body
43
+ req
44
+ end
45
+
46
+ def execute(uri, request)
47
+ timeout = RailsWebhookOutbox.config.request_timeout
48
+ Net::HTTP.start(
49
+ uri.host, uri.port,
50
+ use_ssl: uri.scheme == "https",
51
+ open_timeout: timeout,
52
+ read_timeout: timeout
53
+ ) do |http|
54
+ http.request(request)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,14 @@
1
+ require "openssl"
2
+
3
+ module RailsWebhookOutbox
4
+ module Signature
5
+ def self.sign(payload, secret, algorithm)
6
+ OpenSSL::HMAC.hexdigest(algorithm.to_s.upcase, secret, payload)
7
+ end
8
+
9
+ def self.header_value(payload, secret)
10
+ algorithm = RailsWebhookOutbox.config.signing_algorithm
11
+ "#{algorithm}=#{sign(payload, secret, algorithm)}"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,3 @@
1
+ module RailsWebhookOutbox
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,39 @@
1
+ require "rails_webhook_outbox/version"
2
+ require "rails_webhook_outbox/configuration"
3
+ require "rails_webhook_outbox/signature"
4
+ require "rails_webhook_outbox/delivery_error"
5
+ require "rails_webhook_outbox/sender"
6
+ require "rails_webhook_outbox/dispatchable"
7
+ require "rails_webhook_outbox/engine"
8
+
9
+ module RailsWebhookOutbox
10
+ class << self
11
+ def configuration
12
+ @configuration ||= Configuration.new
13
+ end
14
+
15
+ alias_method :config, :configuration
16
+
17
+ def configure
18
+ yield(configuration)
19
+ end
20
+
21
+ def reset_configuration!
22
+ @configuration = Configuration.new
23
+ end
24
+
25
+ def dispatch(event, payload)
26
+ Subscription.active.each do |subscription|
27
+ next unless subscription.subscribes_to?(event)
28
+
29
+ delivery = Delivery.create!(
30
+ subscription: subscription,
31
+ event: event,
32
+ payload: payload
33
+ )
34
+
35
+ DeliveryJob.perform_later(delivery)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :rails_webhook_outbox do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_webhook_outbox
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Chuck Smith
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.2'
26
+ description: A Rails engine for sending outgoing webhooks with HMAC signing, ActiveJob-based
27
+ retry, and delivery logging.
28
+ email:
29
+ - eclectic-coding@users.noreply.github.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - MIT-LICENSE
35
+ - README.md
36
+ - Rakefile
37
+ - app/controllers/rails_webhook_outbox/application_controller.rb
38
+ - app/jobs/rails_webhook_outbox/application_job.rb
39
+ - app/jobs/rails_webhook_outbox/delivery_job.rb
40
+ - app/mailers/rails_webhook_outbox/application_mailer.rb
41
+ - app/models/rails_webhook_outbox/application_record.rb
42
+ - app/models/rails_webhook_outbox/delivery.rb
43
+ - app/models/rails_webhook_outbox/subscription.rb
44
+ - config/routes.rb
45
+ - db/migrate/20260624000001_create_webhook_outbox_subscriptions.rb
46
+ - db/migrate/20260624000002_create_webhook_outbox_deliveries.rb
47
+ - 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
50
+ - lib/generators/rails_webhook_outbox/install/templates/initializer.rb
51
+ - lib/rails_webhook_outbox.rb
52
+ - lib/rails_webhook_outbox/configuration.rb
53
+ - lib/rails_webhook_outbox/delivery_error.rb
54
+ - lib/rails_webhook_outbox/dispatchable.rb
55
+ - lib/rails_webhook_outbox/engine.rb
56
+ - lib/rails_webhook_outbox/sender.rb
57
+ - lib/rails_webhook_outbox/signature.rb
58
+ - lib/rails_webhook_outbox/version.rb
59
+ - lib/tasks/rails_webhook_outbox_tasks.rake
60
+ homepage: https://github.com/eclectic-coding/rails_webhook_outbox
61
+ licenses:
62
+ - MIT
63
+ metadata:
64
+ allowed_push_host: https://rubygems.org
65
+ homepage_uri: https://github.com/eclectic-coding/rails_webhook_outbox
66
+ source_code_uri: https://github.com/eclectic-coding/rails_webhook_outbox
67
+ changelog_uri: https://github.com/eclectic-coding/rails_webhook_outbox/blob/main/CHANGELOG.md
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.6.9
83
+ specification_version: 4
84
+ summary: Outgoing webhooks for Rails with HMAC signing and ActiveJob retry
85
+ test_files: []