rails_webhook_outbox 0.2.0 → 0.4.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +218 -4
  3. data/Rakefile +6 -1
  4. data/UPGRADING.md +92 -0
  5. data/app/assets/stylesheets/rails_webhook_outbox/_01_base.css +53 -0
  6. data/app/assets/stylesheets/rails_webhook_outbox/_02_layout.css +88 -0
  7. data/app/assets/stylesheets/rails_webhook_outbox/_03_table.css +26 -0
  8. data/app/assets/stylesheets/rails_webhook_outbox/_04_badges.css +19 -0
  9. data/app/assets/stylesheets/rails_webhook_outbox/_05_forms.css +42 -0
  10. data/app/assets/stylesheets/rails_webhook_outbox/_06_filters.css +13 -0
  11. data/app/assets/stylesheets/rails_webhook_outbox/_07_timeline.css +31 -0
  12. data/app/controllers/rails_webhook_outbox/base_controller.rb +15 -0
  13. data/app/controllers/rails_webhook_outbox/deliveries_controller.rb +46 -0
  14. data/app/controllers/rails_webhook_outbox/events_controller.rb +13 -0
  15. data/app/controllers/rails_webhook_outbox/overview_controller.rb +7 -0
  16. data/app/controllers/rails_webhook_outbox/subscriptions_controller.rb +62 -0
  17. data/app/helpers/rails_webhook_outbox/application_helper.rb +29 -0
  18. data/app/javascript/rails_webhook_outbox/application.js +2 -0
  19. data/app/javascript/rails_webhook_outbox/controllers/application.js +4 -0
  20. data/app/javascript/rails_webhook_outbox/controllers/index.js +4 -0
  21. data/app/javascript/rails_webhook_outbox/controllers/secret_controller.js +24 -0
  22. data/app/jobs/rails_webhook_outbox/delivery_job.rb +62 -1
  23. data/app/models/rails_webhook_outbox/delivery.rb +28 -0
  24. data/app/models/rails_webhook_outbox/delivery_attempt.rb +13 -0
  25. data/app/models/rails_webhook_outbox/overview.rb +49 -0
  26. data/app/models/rails_webhook_outbox/subscription.rb +50 -2
  27. data/app/views/layouts/rails_webhook_outbox/application.html.erb +22 -0
  28. data/app/views/rails_webhook_outbox/deliveries/_detail.html.erb +47 -0
  29. data/app/views/rails_webhook_outbox/deliveries/_row.html.erb +7 -0
  30. data/app/views/rails_webhook_outbox/deliveries/_timeline.html.erb +41 -0
  31. data/app/views/rails_webhook_outbox/deliveries/index.html.erb +62 -0
  32. data/app/views/rails_webhook_outbox/deliveries/retry.turbo_stream.erb +2 -0
  33. data/app/views/rails_webhook_outbox/deliveries/show.html.erb +5 -0
  34. data/app/views/rails_webhook_outbox/events/index.html.erb +22 -0
  35. data/app/views/rails_webhook_outbox/overview/_recent_failures.html.erb +29 -0
  36. data/app/views/rails_webhook_outbox/overview/_stats.html.erb +19 -0
  37. data/app/views/rails_webhook_outbox/overview/show.html.erb +8 -0
  38. data/app/views/rails_webhook_outbox/subscriptions/_form.html.erb +49 -0
  39. data/app/views/rails_webhook_outbox/subscriptions/edit.html.erb +7 -0
  40. data/app/views/rails_webhook_outbox/subscriptions/index.html.erb +37 -0
  41. data/app/views/rails_webhook_outbox/subscriptions/new.html.erb +7 -0
  42. data/app/views/rails_webhook_outbox/subscriptions/show.html.erb +49 -0
  43. data/config/importmap.rb +6 -0
  44. data/config/routes.rb +17 -0
  45. data/db/migrate/20260624000001_create_webhook_outbox_subscriptions.rb +1 -1
  46. data/db/migrate/20260624000002_create_webhook_outbox_deliveries.rb +4 -4
  47. data/db/migrate/20260629000002_add_idempotency_key_to_webhook_outbox_deliveries.rb +2 -2
  48. data/db/migrate/20260701000001_add_secret_rotation_to_webhook_outbox_subscriptions.rb +6 -0
  49. data/db/migrate/20260701000002_add_consecutive_failures_to_webhook_outbox_subscriptions.rb +5 -0
  50. data/db/migrate/20261027000001_create_webhook_outbox_delivery_attempts.rb +16 -0
  51. data/lib/generators/rails_webhook_outbox/install/install_generator.rb +1 -19
  52. data/lib/rails_webhook_outbox/configuration.rb +14 -1
  53. data/lib/rails_webhook_outbox/engine.rb +16 -0
  54. data/lib/rails_webhook_outbox/secret_rotation_error.rb +8 -0
  55. data/lib/rails_webhook_outbox/sender.rb +2 -1
  56. data/lib/rails_webhook_outbox/signature.rb +2 -2
  57. data/lib/rails_webhook_outbox/version.rb +1 -1
  58. data/lib/rails_webhook_outbox.rb +1 -0
  59. data/lib/tasks/rails_webhook_outbox_tasks.rake +51 -4
  60. metadata +70 -3
  61. data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_deliveries.rb.tt +0 -21
  62. data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb.tt +0 -13
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 78b2ef77e9c0d4c8e0805566fd1da1e813b5e60163b3032f0584267a925803e3
4
- data.tar.gz: 9a3154d6f2029879ed18aaf83548e9a950f27a27057c9a3c517363eaca21ca3e
3
+ metadata.gz: b4c853b16c9d48f970ebe2ccb83c7de3e1d6789d93a9e78407f788b6a5f8dd8d
4
+ data.tar.gz: a9a380da628a0f00c0d6d5ac08d2351edbc1e0fc3084b4d73ce6dab7e1f28c44
5
5
  SHA512:
6
- metadata.gz: 4e527d2e705089dc96fbba1f913c15d03a5c1ed54b221c4e26dbd1b97d3aae10ad58dc8f910d1db090d6846438302fbef079c78cac0a58b6bca0b50c7c941b2d
7
- data.tar.gz: fd935881ea40dc8e68fd3b4df3a240adf2f23b7c60e5ce938865a1f59b2f74e4e3a62123bffa6553428bb52b709d4e289adb5219bcaf75ec64b16ca38ca7c265
6
+ metadata.gz: 452c56fedabd11ecd4a9388af0fa7de18b4988c60585c4b567a2b28d9657ee5226ba3b492bcab0752cc29be2d843e2c329092c32391e52311e85dfd460c24f85
7
+ data.tar.gz: e464b60386edada571d92ad803f0c074e2b152fb4438b63d4e843b1833ff0d65686074a8fe0cc62d3817b50241d28aebc068d47605443be5cc4c6e5a5defa657
data/README.md CHANGED
@@ -17,13 +17,20 @@ 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
+ - [Dashboard](#dashboard)
27
+ - [Rake Tasks](#rake-tasks)
22
28
  - [Usage](#usage)
23
29
  - [Manual Dispatch](#manual-dispatch)
24
30
  - [Testing](#testing)
25
31
  - [Development](#development)
26
32
  - [Dummy App](#dummy-app)
33
+ - [Upgrading](#upgrading)
27
34
  - [Contributing](#contributing)
28
35
  - [License](#license)
29
36
 
@@ -41,13 +48,16 @@ And then execute:
41
48
  $ bundle install
42
49
  ```
43
50
 
44
- Run the install generator:
51
+ Run the install generator, then migrate:
45
52
 
46
53
  ```bash
47
54
  $ rails generate rails_webhook_outbox:install
48
55
  $ rails db:migrate
49
56
  ```
50
57
 
58
+ The generator creates `config/initializers/rails_webhook_outbox.rb`. It does not copy any migrations:
59
+ the engine adds its own to your app's migration paths, so `rails db:migrate` creates the tables.
60
+
51
61
  ## Configuration
52
62
 
53
63
  ```ruby
@@ -66,6 +76,9 @@ RailsWebhookOutbox.configure do |config|
66
76
  config.request_timeout = 5
67
77
  config.delivery_job_queue = :webhooks
68
78
  config.max_payload_size = 65_536 # bytes; set to nil or 0 to disable
79
+ config.secret_rotation_grace_period = 24.hours
80
+ config.circuit_breaker_threshold = 10 # consecutive permanent failures before auto-disabling; nil or 0 disables
81
+ config.dashboard_enabled = true # enable the mountable dashboard UI; defaults to true in development
69
82
  end
70
83
  ```
71
84
 
@@ -129,6 +142,23 @@ RailsWebhookOutbox::Delivery.delivered # successfully delivered
129
142
  RailsWebhookOutbox::Delivery.failed # exhausted all retries
130
143
  ```
131
144
 
145
+ Every HTTP attempt is also recorded as a `RailsWebhookOutbox::DeliveryAttempt`, so earlier failures
146
+ are kept after a retry succeeds (the delivery itself only stores the latest response):
147
+
148
+ ```ruby
149
+ delivery.delivery_attempts.each do |attempt|
150
+ attempt.attempt_number # => 1, 2, 3, ...
151
+ attempt.outcome # => "failed" or "delivered"
152
+ attempt.response_code # => 503
153
+ attempt.response_body # => truncated to 1000 characters
154
+ attempt.duration_ms # => 182
155
+ attempt.created_at
156
+ end
157
+ ```
158
+
159
+ Attempts are deleted with their delivery, including by `webhook_outbox:cleanup`. Upgrading from a
160
+ version before 0.4.0 requires a migration — see [Upgrading](#upgrading).
161
+
132
162
  [Back to top](#table-of-contents)
133
163
 
134
164
  ## Async Delivery
@@ -162,6 +192,69 @@ RailsWebhookOutbox::DeliveryJob.perform_later(delivery)
162
192
 
163
193
  [Back to top](#table-of-contents)
164
194
 
195
+ ## Instrumentation
196
+
197
+ `DeliveryJob` publishes `ActiveSupport::Notifications` events you can subscribe to for logging, metrics, or alerting:
198
+
199
+ | Event | When |
200
+ |-------|------|
201
+ | `webhook.delivered.rails_webhook_outbox` | Delivery succeeded (2xx response) |
202
+ | `webhook.failed.rails_webhook_outbox` | All retries exhausted — permanent failure |
203
+ | `webhook.circuit_breaker_tripped.rails_webhook_outbox` | A permanent failure auto-disabled the subscription — see [Circuit Breaker](#circuit-breaker) |
204
+
205
+ `webhook.delivered` / `webhook.failed` payloads include:
206
+
207
+ | Key | Type | Description |
208
+ |-----|------|-------------|
209
+ | `event` | String | Webhook event name, e.g. `"order.created"` |
210
+ | `subscription_id` | Integer | ID of the `Subscription` record |
211
+ | `delivery_id` | Integer | ID of the `Delivery` record |
212
+ | `duration` | Integer | HTTP round-trip time in milliseconds |
213
+
214
+ `webhook.circuit_breaker_tripped` payloads include `subscription_id` and `consecutive_failures`.
215
+
216
+ Subscribe in an initializer:
217
+
218
+ ```ruby
219
+ ActiveSupport::Notifications.subscribe("webhook.delivered.rails_webhook_outbox") do |*args|
220
+ event = ActiveSupport::Notifications::Event.new(*args)
221
+ Rails.logger.info "[webhook] delivered #{event.payload[:event]} in #{event.payload[:duration]}ms"
222
+ end
223
+
224
+ ActiveSupport::Notifications.subscribe("webhook.failed.rails_webhook_outbox") do |*args|
225
+ event = ActiveSupport::Notifications::Event.new(*args)
226
+ Sentry.capture_message("Webhook permanently failed", extra: event.payload)
227
+ end
228
+ ```
229
+
230
+ [Back to top](#table-of-contents)
231
+
232
+ ## Logging
233
+
234
+ `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.).
235
+
236
+ | Source | Level | When | Keys logged |
237
+ |--------|-------|------|-------------|
238
+ | `Sender` | `info` | Before each HTTP call | `event`, `key` (idempotency key), `url` |
239
+ | `DeliveryJob` | `info` | Successful delivery | `event`, `delivery_id`, `subscription_id`, `status`, `duration` |
240
+ | `DeliveryJob` | `warn` | Retryable failure | `event`, `delivery_id`, `subscription_id`, `status`, `attempt`, `next_retry_at` |
241
+ | `DeliveryJob` | `error` | Permanent failure (all retries exhausted) | `event`, `delivery_id`, `subscription_id`, `status`, `attempts` |
242
+ | `DeliveryJob` | `warn` | Circuit breaker tripped | `subscription_id`, `consecutive_failures` |
243
+
244
+ Example lines:
245
+
246
+ ```
247
+ [RailsWebhookOutbox] attempt event=order.created key=550e8400-e29b-41d4-a716-446655440000 url=https://example.com/webhooks
248
+ [RailsWebhookOutbox] delivered event=order.created delivery_id=1 subscription_id=1 status=200 duration=45ms
249
+ [RailsWebhookOutbox] retry event=order.created delivery_id=1 subscription_id=1 status=503 attempt=1 next_retry_at=2026-07-01T00:00:13Z
250
+ [RailsWebhookOutbox] failed event=order.created delivery_id=1 subscription_id=1 status=503 attempts=3
251
+ [RailsWebhookOutbox] circuit_breaker_tripped subscription_id=1 consecutive_failures=10
252
+ ```
253
+
254
+ No configuration is required — logging is always on and respects your application's log level.
255
+
256
+ [Back to top](#table-of-contents)
257
+
165
258
  ## HTTP Request Format
166
259
 
167
260
  Each webhook delivery is an HTTP POST to the subscription URL with the following headers and body:
@@ -195,11 +288,14 @@ Every outgoing request includes an `X-Webhook-Signature` header (configurable) c
195
288
  X-Webhook-Signature: sha256=a1b2c3d4...
196
289
  ```
197
290
 
198
- Subscribers can verify the signature:
291
+ Subscribers can verify the signature. The header may contain more than one comma-separated
292
+ `algorithm=digest` pair while a subscription's secret is [rotating](#secret-rotation), so check
293
+ each one and accept the request if any match:
199
294
 
200
295
  ```ruby
201
- expected = RailsWebhookOutbox::Signature.header_value(raw_body, subscription.secret)
202
- Rack::Utils.secure_compare(expected, request.headers["X-Webhook-Signature"])
296
+ expected = RailsWebhookOutbox::Signature.sign(raw_body, subscription.secret, :sha256)
297
+ signatures = request.headers["X-Webhook-Signature"].to_s.split(",")
298
+ valid = signatures.any? { |sig| Rack::Utils.secure_compare(sig, "sha256=#{expected}") }
203
299
  ```
204
300
 
205
301
  You can also call the primitives directly:
@@ -214,6 +310,114 @@ RailsWebhookOutbox::Signature.header_value(payload, secret)
214
310
  # => "sha256=a1b2c3d4..."
215
311
  ```
216
312
 
313
+ `header_value` also accepts an array of secrets, signing the payload with each one and joining the
314
+ results with a comma — this is how dual-secret signing works during [secret rotation](#secret-rotation).
315
+
316
+ [Back to top](#table-of-contents)
317
+
318
+ ## Secret Rotation
319
+
320
+ Rotate a subscription's HMAC secret without dropping any webhook deliveries:
321
+
322
+ ```ruby
323
+ sub.rotate_secret!
324
+ ```
325
+
326
+ This generates a new secret and moves the old one to `previous_secret`, valid until
327
+ `previous_secret_expires_at` (set from `config.secret_rotation_grace_period`, default 24 hours).
328
+ Pass `grace_period:` to override it for a single rotation:
329
+
330
+ ```ruby
331
+ sub.rotate_secret!(grace_period: 3.days)
332
+ ```
333
+
334
+ Calling `rotate_secret!` again while a previous secret is still within its grace period raises
335
+ `RailsWebhookOutbox::SecretRotationError`, since the schema only holds one previous secret — doing
336
+ so would silently invalidate a secret subscribers may not have finished transitioning to yet. Pass
337
+ `force: true` to rotate anyway and discard it immediately.
338
+
339
+ While `previous_secret` is still active, every outgoing request is signed with **both** secrets —
340
+ `X-Webhook-Signature` carries a comma-separated list of `algorithm=digest` pairs:
341
+
342
+ ```
343
+ X-Webhook-Signature: sha256=<new-secret-digest>,sha256=<previous-secret-digest>
344
+ ```
345
+
346
+ See [HMAC Signing](#hmac-signing) for how subscribers should verify a header that may contain more
347
+ than one signature.
348
+
349
+ [Back to top](#table-of-contents)
350
+
351
+ ## Circuit Breaker
352
+
353
+ Each `Subscription` tracks `consecutive_failures` — how many deliveries in a row have permanently
354
+ failed (all retries exhausted). A successful delivery resets it to zero.
355
+
356
+ Once `consecutive_failures` reaches `config.circuit_breaker_threshold` (default 10), the
357
+ subscription is automatically disabled (`active: false`) so a persistently failing endpoint stops
358
+ being hammered — deliveries already in flight are skipped rather than retried further once their
359
+ subscription is disabled — and `webhook.circuit_breaker_tripped.rails_webhook_outbox` is published:
360
+
361
+ ```ruby
362
+ ActiveSupport::Notifications.subscribe("webhook.circuit_breaker_tripped.rails_webhook_outbox") do |*args|
363
+ event = ActiveSupport::Notifications::Event.new(*args)
364
+ Sentry.capture_message("Webhook subscription auto-disabled",
365
+ extra: event.payload.slice(:subscription_id, :consecutive_failures))
366
+ end
367
+ ```
368
+
369
+ Set `config.circuit_breaker_threshold` to `nil` or `0` to disable auto-disabling entirely. Retryable
370
+ (non-final) failures don't count towards the threshold — only a delivery that has exhausted all
371
+ retries counts as one consecutive failure. Re-enable a tripped subscription with
372
+ `sub.update!(active: true)`; `consecutive_failures` resets to zero immediately on reactivation, so a
373
+ single subsequent failure won't instantly re-trip the breaker.
374
+
375
+ [Back to top](#table-of-contents)
376
+
377
+ ## Dashboard
378
+
379
+ Mount the engine to get a browser dashboard for managing subscriptions and browsing deliveries:
380
+
381
+ ```ruby
382
+ # config/routes.rb
383
+ mount RailsWebhookOutbox::Engine => "/admin/webhooks"
384
+ ```
385
+
386
+ The dashboard is gated by `config.dashboard_enabled` (default: `true` in development, `false`
387
+ elsewhere) — set it explicitly to control access, and put the mount behind your own
388
+ authentication/authorization in `routes.rb` (e.g. `constraints AdminConstraint.new do ... end`) since
389
+ the engine itself has no concept of users.
390
+
391
+ Pages:
392
+
393
+ | Page | Purpose |
394
+ |------|---------|
395
+ | `/` | Overview — subscription/delivery counts, recent failures |
396
+ | `/subscriptions` | List, create, and edit subscriptions; enable/disable with one click |
397
+ | `/subscriptions/:id` | Detail — masked signing secret with reveal/copy, rotate-secret button |
398
+ | `/deliveries` | Filterable delivery log (status, event, date range) with live status updates via Turbo Streams |
399
+ | `/deliveries/:id` | Detail — attempt timeline, payload, response, and a manual retry button for failed deliveries |
400
+ | `/events` | Registered events with subscriber counts and a recent payload example |
401
+
402
+ The UI ships its own assets (CSS inlined, JS via importmap) and follows the system light/dark color
403
+ scheme automatically — no asset pipeline configuration is required beyond what a standard Rails 7+
404
+ app already has.
405
+
406
+ [Back to top](#table-of-contents)
407
+
408
+ ## Rake Tasks
409
+
410
+ ```bash
411
+ bin/rails webhook_outbox:retry_failed # re-enqueue all failed deliveries for retry
412
+ bin/rails webhook_outbox:list_subscriptions # list subscriptions with status, events, and failure count
413
+ bin/rails webhook_outbox:cleanup[7] # delete delivered/failed deliveries older than N days
414
+ ```
415
+
416
+ `retry_failed` resets each failed delivery to `pending` and enqueues a `DeliveryJob`; deliveries
417
+ whose subscription has since been disabled are skipped by the job itself, not by the task.
418
+ `cleanup[days]` requires a positive integer argument and only removes deliveries in a terminal
419
+ state (`delivered` or `failed`) — `pending` deliveries are never deleted.
420
+
217
421
  [Back to top](#table-of-contents)
218
422
 
219
423
  ## Usage
@@ -384,6 +588,16 @@ RailsWebhookOutbox::Subscription.first.update!(url: "https://webhook.site/your-i
384
588
 
385
589
  [Back to top](#table-of-contents)
386
590
 
591
+ ## Upgrading
592
+
593
+ Some releases need action from you, such as a database migration. Read [UPGRADING.md](UPGRADING.md)
594
+ before updating the gem, and check the **BREAKING** entries in the [CHANGELOG](CHANGELOG.md).
595
+
596
+ **0.4.0 requires a new table** (`webhook_outbox_delivery_attempts`): run `bin/rails db:migrate`
597
+ before deploying. See [Upgrading to 0.4.0](UPGRADING.md#upgrading-to-040).
598
+
599
+ [Back to top](#table-of-contents)
600
+
387
601
  ## Contributing
388
602
 
389
603
  Bug reports and pull requests are welcome on [GitHub](https://github.com/eclectic-coding/rails_webhook_outbox).
data/Rakefile CHANGED
@@ -13,4 +13,9 @@ RuboCop::RakeTask.new(:lint)
13
13
  Bundler::Audit::Task.new
14
14
  RSpec::Core::RakeTask.new(:spec)
15
15
 
16
- task default: [:lint, :'bundle:audit:update', 'bundle:audit:check', :spec]
16
+ desc "Run Brakeman static security analysis"
17
+ task :brakeman do
18
+ sh "bundle exec brakeman -q --no-pager --force-scan --skip-files spec/ ."
19
+ end
20
+
21
+ task default: [:lint, :'bundle:audit:update', 'bundle:audit:check', :brakeman, :spec]
data/UPGRADING.md ADDED
@@ -0,0 +1,92 @@
1
+ # Upgrading
2
+
3
+ Notes for upgrades that need action from you, newest version first. For everything that changed in a
4
+ release, see the [CHANGELOG](CHANGELOG.md).
5
+
6
+ ## Upgrading to 0.4.0
7
+
8
+ **This release has a breaking change: it requires a new database table.** Run the migration _before_
9
+ the 0.4.0 code starts processing deliveries.
10
+
11
+ ### What changed
12
+
13
+ `DeliveryJob` now records every HTTP attempt as a `RailsWebhookOutbox::DeliveryAttempt` in the new
14
+ `webhook_outbox_delivery_attempts` table. This is the data behind the delivery timeline in the
15
+ dashboard. Previously only the latest response was kept on the delivery itself.
16
+
17
+ ### Steps
18
+
19
+ 1. Update the gem:
20
+
21
+ ```bash
22
+ bundle update rails_webhook_outbox
23
+ ```
24
+
25
+ 2. Run the migration. The engine adds its migrations to your app's migration paths, so there is
26
+ nothing to copy:
27
+
28
+ ```bash
29
+ bin/rails db:migrate
30
+ ```
31
+
32
+ 3. Confirm it ran:
33
+
34
+ ```bash
35
+ bin/rails db:migrate:status | grep delivery_attempts
36
+ # up 20261027000001 Create webhook outbox delivery attempts
37
+ ```
38
+
39
+ In a deploy pipeline, run `db:migrate` before the new code starts serving requests or running jobs
40
+ (the usual release-phase step on most hosts).
41
+
42
+ ### If you ran the install generator on an earlier version
43
+
44
+ Before 0.4.0, `rails generate rails_webhook_outbox:install` also copied the engine's table migrations
45
+ into your app's `db/migrate`. The engine adds those same migrations to your app on its own, so with
46
+ the copies present `db:migrate` fails:
47
+
48
+ ```
49
+ ActiveRecord::DuplicateMigrationNameError: Multiple migrations have the name CreateWebhookOutboxSubscriptions.
50
+ ```
51
+
52
+ To fix it, delete the copies and migrate. The engine's migrations now skip tables, columns, and
53
+ indexes that already exist, so they are safe to run against a database the copies already built:
54
+
55
+ ```bash
56
+ rm db/migrate/*_create_webhook_outbox_subscriptions.rb \
57
+ db/migrate/*_create_webhook_outbox_deliveries.rb
58
+ bin/rails db:migrate
59
+ ```
60
+
61
+ Commit the deletions. `db:migrate:status` will keep listing the old copied versions as `NO FILE`;
62
+ that is harmless. The run also adds anything the copies lacked, such as the unique index on
63
+ `idempotency_key`, and creates the new `webhook_outbox_delivery_attempts` table.
64
+
65
+ ### If you deploy the code without migrating
66
+
67
+ `DeliveryJob` sends the HTTP request first and records the attempt afterwards. Without the table,
68
+ recording the attempt raises `ActiveRecord::StatementInvalid`, so:
69
+
70
+ - the webhook **is still sent** to the subscriber, but the delivery is left `pending` with
71
+ `attempts: 0`, and the engine's `DeliveryError` retry does not apply to this error;
72
+ - every job fails the same way until the migration is run.
73
+
74
+ Run `db:migrate`, then re-enqueue the stuck deliveries:
75
+
76
+ ```ruby
77
+ RailsWebhookOutbox::Delivery.pending.find_each { |d| RailsWebhookOutbox::DeliveryJob.perform_later(d) }
78
+ ```
79
+
80
+ The subscriber will receive those webhooks a second time. It can deduplicate them with the
81
+ `X-Webhook-Delivery` header, which is stable across attempts. (`webhook_outbox:retry_failed` does not
82
+ help here: it only re-enqueues `failed` deliveries.)
83
+
84
+ ### Existing data
85
+
86
+ Deliveries created before the upgrade have no attempt history; it starts with the first attempt made
87
+ after the migration. Nothing is backfilled.
88
+
89
+ ### Downgrading
90
+
91
+ Downgrading the gem back to 0.3.0 is safe without rolling the migration back: 0.3.0 never reads the
92
+ table, and the foreign key cascades on delete, so `webhook_outbox:cleanup` keeps working.
@@ -0,0 +1,53 @@
1
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2
+
3
+ :root {
4
+ --bg: #f8f9fa;
5
+ --surface: #ffffff;
6
+ --border: #dee2e6;
7
+ --text: #212529;
8
+ --muted: #6c757d;
9
+ --accent: #4f46e5;
10
+ --accent-text: #ffffff;
11
+ --danger: #dc3545;
12
+ --success-bg: #d1e7dd;
13
+ --success-text: #0f5132;
14
+ --warning-bg: #fff3cd;
15
+ --warning-text: #664d03;
16
+ --danger-bg: #f8d7da;
17
+ --danger-text: #842029;
18
+ --radius: 6px;
19
+ --shadow: 0 1px 3px rgba(0,0,0,.08);
20
+ }
21
+
22
+ @media (prefers-color-scheme: dark) {
23
+ :root {
24
+ --bg: #16181d;
25
+ --surface: #1f2229;
26
+ --border: #333844;
27
+ --text: #e9ecef;
28
+ --muted: #9aa1ac;
29
+ --accent: #7c7ff2;
30
+ --accent-text: #ffffff;
31
+ --danger: #e25563;
32
+ --success-bg: #10321f;
33
+ --success-text: #7fe0a4;
34
+ --warning-bg: #3a2f0b;
35
+ --warning-text: #f0c96b;
36
+ --danger-bg: #3a1216;
37
+ --danger-text: #f0949c;
38
+ --shadow: 0 1px 3px rgba(0,0,0,.4);
39
+ }
40
+ }
41
+
42
+ body {
43
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
44
+ font-size: 14px;
45
+ line-height: 1.5;
46
+ color: var(--text);
47
+ background: var(--bg);
48
+ }
49
+
50
+ a { color: var(--accent); }
51
+
52
+ .rwo-monospace { font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; font-size: 12px; }
53
+ .rwo-muted { color: var(--muted); font-size: 12px; }
@@ -0,0 +1,88 @@
1
+ .rwo-nav {
2
+ display: flex;
3
+ gap: 1.5rem;
4
+ padding: 1rem 2rem;
5
+ background: var(--surface);
6
+ border-bottom: 1px solid var(--border);
7
+ }
8
+
9
+ .rwo-nav-link {
10
+ color: var(--text);
11
+ text-decoration: none;
12
+ font-weight: 600;
13
+ }
14
+
15
+ .rwo-nav-link:hover { color: var(--accent); }
16
+
17
+ .rwo-main { padding: 2rem; max-width: 1100px; margin: 0 auto; }
18
+
19
+ .rwo-header { margin-bottom: 1.5rem; }
20
+
21
+ .rwo-header-row {
22
+ display: flex;
23
+ align-items: center;
24
+ justify-content: space-between;
25
+ gap: 1rem;
26
+ margin-bottom: 1rem;
27
+ }
28
+
29
+ .rwo-section-title { margin: 1.5rem 0 0.75rem; font-size: 16px; }
30
+
31
+ .rwo-back { display: inline-block; margin-bottom: 1rem; font-size: 13px; }
32
+
33
+ .rwo-empty { color: var(--muted); padding: 1rem 0; }
34
+
35
+ .rwo-stats-grid {
36
+ display: grid;
37
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
38
+ gap: 1rem;
39
+ margin-bottom: 2rem;
40
+ }
41
+
42
+ .rwo-stat {
43
+ background: var(--surface);
44
+ border: 1px solid var(--border);
45
+ border-radius: var(--radius);
46
+ box-shadow: var(--shadow);
47
+ padding: 1rem;
48
+ }
49
+
50
+ .rwo-stat-label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
51
+ .rwo-stat-value { font-size: 28px; font-weight: 700; }
52
+
53
+ .rwo-detail {
54
+ background: var(--surface);
55
+ border: 1px solid var(--border);
56
+ border-radius: var(--radius);
57
+ box-shadow: var(--shadow);
58
+ padding: 1rem;
59
+ margin-bottom: 1rem;
60
+ }
61
+
62
+ .rwo-detail-grid {
63
+ display: grid;
64
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
65
+ gap: 1rem;
66
+ }
67
+
68
+ .rwo-detail-label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
69
+ .rwo-detail-value { font-size: 15px; }
70
+
71
+ .rwo-code-block {
72
+ background: var(--bg);
73
+ border: 1px solid var(--border);
74
+ border-radius: var(--radius);
75
+ padding: 1rem;
76
+ overflow-x: auto;
77
+ white-space: pre-wrap;
78
+ word-break: break-word;
79
+ }
80
+
81
+ .rwo-event {
82
+ background: var(--surface);
83
+ border: 1px solid var(--border);
84
+ border-radius: var(--radius);
85
+ box-shadow: var(--shadow);
86
+ padding: 1rem;
87
+ margin-bottom: 1rem;
88
+ }
@@ -0,0 +1,26 @@
1
+ .rwo-table {
2
+ width: 100%;
3
+ border-collapse: collapse;
4
+ background: var(--surface);
5
+ border: 1px solid var(--border);
6
+ border-radius: var(--radius);
7
+ overflow: hidden;
8
+ }
9
+
10
+ .rwo-table th, .rwo-table td {
11
+ text-align: left;
12
+ padding: 0.6rem 0.75rem;
13
+ border-bottom: 1px solid var(--border);
14
+ }
15
+
16
+ .rwo-table th { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
17
+
18
+ .rwo-table tbody tr:last-child td { border-bottom: none; }
19
+
20
+ .rwo-path { font-weight: 600; }
21
+
22
+ .rwo-pagination {
23
+ display: flex;
24
+ justify-content: space-between;
25
+ margin-top: 1rem;
26
+ }
@@ -0,0 +1,19 @@
1
+ .rwo-badge {
2
+ display: inline-block;
3
+ padding: 0.2rem 0.6rem;
4
+ border-radius: 999px;
5
+ font-size: 12px;
6
+ font-weight: 600;
7
+ }
8
+
9
+ .rwo-badge--delivered { background: var(--success-bg); color: var(--success-text); }
10
+ .rwo-badge--pending { background: var(--warning-bg); color: var(--warning-text); }
11
+ .rwo-badge--failed { background: var(--danger-bg); color: var(--danger-text); }
12
+
13
+ .rwo-alert {
14
+ background: var(--danger-bg);
15
+ color: var(--danger-text);
16
+ border-radius: var(--radius);
17
+ padding: 0.75rem 1rem;
18
+ margin-bottom: 1rem;
19
+ }
@@ -0,0 +1,42 @@
1
+ .rwo-form { max-width: 480px; }
2
+
3
+ .rwo-field { margin-bottom: 1rem; }
4
+
5
+ .rwo-field label { display: block; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 0.3rem; }
6
+
7
+ .rwo-input, .rwo-select {
8
+ width: 100%;
9
+ padding: 0.5rem 0.6rem;
10
+ border: 1px solid var(--border);
11
+ border-radius: var(--radius);
12
+ background: var(--surface);
13
+ color: var(--text);
14
+ font-size: 14px;
15
+ }
16
+
17
+ .rwo-checkbox-group { display: flex; flex-direction: column; gap: 0.4rem; }
18
+ .rwo-checkbox-label { display: flex; align-items: center; gap: 0.4rem; font-weight: 400; text-transform: none; letter-spacing: normal; }
19
+
20
+ .rwo-btn, .rwo-btn-small, .rwo-btn-danger, .rwo-btn-reset {
21
+ display: inline-block;
22
+ border: none;
23
+ border-radius: var(--radius);
24
+ cursor: pointer;
25
+ font-size: 14px;
26
+ font-weight: 600;
27
+ text-decoration: none;
28
+ }
29
+
30
+ .rwo-btn { background: var(--accent); color: var(--accent-text); padding: 0.55rem 1rem; }
31
+ .rwo-btn-small { background: var(--surface); color: var(--text); border: 1px solid var(--border); padding: 0.35rem 0.7rem; font-size: 12px; }
32
+ .rwo-btn-danger { background: var(--danger); color: #fff; padding: 0.55rem 1rem; }
33
+ .rwo-btn-reset { background: transparent; color: var(--muted); padding: 0.55rem 0.75rem; }
34
+
35
+ .rwo-secret {
36
+ display: flex;
37
+ align-items: center;
38
+ gap: 0.6rem;
39
+ margin-bottom: 0.5rem;
40
+ }
41
+
42
+ .rwo-secret-display { font-size: 14px; }
@@ -0,0 +1,13 @@
1
+ .rwo-filters {
2
+ display: flex;
3
+ flex-wrap: wrap;
4
+ align-items: end;
5
+ gap: 1rem;
6
+ background: var(--surface);
7
+ border: 1px solid var(--border);
8
+ border-radius: var(--radius);
9
+ padding: 1rem;
10
+ margin-bottom: 1.5rem;
11
+ }
12
+
13
+ .rwo-filters .rwo-field { margin-bottom: 0; min-width: 140px; }
@@ -0,0 +1,31 @@
1
+ .rwo-timeline {
2
+ list-style: none;
3
+ margin-left: 6px;
4
+ border-left: 2px solid var(--border);
5
+ }
6
+
7
+ .rwo-timeline-item {
8
+ position: relative;
9
+ padding: 0 0 1rem 1.25rem;
10
+ }
11
+
12
+ .rwo-timeline-item::before {
13
+ content: "";
14
+ position: absolute;
15
+ left: -7px;
16
+ top: 4px;
17
+ width: 12px;
18
+ height: 12px;
19
+ border-radius: 50%;
20
+ border: 2px solid var(--bg);
21
+ background: var(--muted);
22
+ }
23
+
24
+ .rwo-timeline-item--delivered::before { background: var(--success-text); }
25
+ .rwo-timeline-item--failed::before { background: var(--danger); }
26
+ .rwo-timeline-item--scheduled::before { background: var(--warning-text); }
27
+
28
+ .rwo-timeline-title { font-weight: 600; }
29
+
30
+ .rwo-timeline-response { margin-top: 0.25rem; }
31
+ .rwo-timeline-response summary { cursor: pointer; }