RailsWebhookOutbox
A Rails engine for sending outgoing webhooks with HMAC signing, ActiveJob-based retry, and delivery logging.
Table of Contents
- Installation
- Configuration
- Subscriptions
- Deliveries
- Async Delivery
- Instrumentation
- Logging
- HTTP Request Format
- HMAC Signing
- Secret Rotation
- Circuit Breaker
- Rake Tasks
- Usage
- Manual Dispatch
- Testing
- Development
- Dummy App
- Contributing
- License
Installation
Add this line to your application's Gemfile:
gem "rails_webhook_outbox"And then execute:
$ bundle installRun the install generator:
$ rails generate rails_webhook_outbox:install
$ rails db:migrateConfiguration
# config/initializers/rails_webhook_outbox.rb
RailsWebhookOutbox.configure do |config|
config.events = %w[
order.created
order.updated
user.signed_up
]
config.signing_algorithm = :sha256
config.signing_header = "X-Webhook-Signature"
config.max_retries = 8
config.retry_backoff = :exponential
config.request_timeout = 5
config.delivery_job_queue = :webhooks
config.max_payload_size = 65_536 # bytes; set to nil or 0 to disable
config.secret_rotation_grace_period = 24.hours
config.circuit_breaker_threshold = 10 # consecutive permanent failures before auto-disabling; nil or 0 disables
endWhen 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.
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).
Back to top
Subscriptions
A RailsWebhookOutbox::Subscription represents an endpoint that receives webhook events.
sub = RailsWebhookOutbox::Subscription.create!(
url: "https://example.com/webhooks",
events: ["order.created", "order.updated"]
)
sub.secret # => "a3f9..." (auto-generated 64-char hex string)
sub.active? # => true (default)
sub.subscribes_to?("order.created") # => true
sub.subscribes_to?("payment.failed") # => falseUse the active scope to find enabled subscriptions:
RailsWebhookOutbox::Subscription.activeDisable a subscription by setting active: false:
sub.update!(active: false)Back to top
Deliveries
A RailsWebhookOutbox::Delivery records each attempt to send a webhook event to a subscription endpoint.
delivery = RailsWebhookOutbox::Delivery.create!(
subscription: subscription,
event: "order.created",
payload: { id: 42, total: "99.00" }
)
delivery.pending? # => true (default)
delivery.delivered!
delivery.delivered? # => trueFilter deliveries by status:
RailsWebhookOutbox::Delivery.retryable # pending — awaiting delivery or retry
RailsWebhookOutbox::Delivery.delivered # successfully delivered
RailsWebhookOutbox::Delivery.failed # exhausted all retriesBack to top
Async Delivery
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.).
Configure the queue and retry behaviour in the initializer:
RailsWebhookOutbox.configure do |config|
config.delivery_job_queue = :webhooks # default
config.max_retries = 8 # default
endThe 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:
| Execution | Outcome | Delivery status |
|---|---|---|
| 1–(n-1) | non-2xx response |
pending — will retry |
n (max_retries) |
non-2xx response |
failed — no further retry |
| any | 2xx response | delivered |
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.
Enqueue a delivery manually:
RailsWebhookOutbox::DeliveryJob.perform_later(delivery)Back to top
Instrumentation
DeliveryJob publishes ActiveSupport::Notifications events you can subscribe to for logging, metrics, or alerting:
| Event | When |
|---|---|
webhook.delivered.rails_webhook_outbox |
Delivery succeeded (2xx response) |
webhook.failed.rails_webhook_outbox |
All retries exhausted — permanent failure |
webhook.circuit_breaker_tripped.rails_webhook_outbox |
A permanent failure auto-disabled the subscription — see Circuit Breaker |
webhook.delivered / webhook.failed payloads include:
| Key | Type | Description |
|---|---|---|
event |
String | Webhook event name, e.g. "order.created"
|
subscription_id |
Integer | ID of the Subscription record |
delivery_id |
Integer | ID of the Delivery record |
duration |
Integer | HTTP round-trip time in milliseconds |
webhook.circuit_breaker_tripped payloads include subscription_id and consecutive_failures.
Subscribe in an initializer:
ActiveSupport::Notifications.subscribe("webhook.delivered.rails_webhook_outbox") do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
Rails.logger.info "[webhook] delivered #{event.payload[:event]} in #{event.payload[:duration]}ms"
end
ActiveSupport::Notifications.subscribe("webhook.failed.rails_webhook_outbox") do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
Sentry.capture_message("Webhook permanently failed", extra: event.payload)
endBack to top
Logging
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.).
| Source | Level | When | Keys logged |
|---|---|---|---|
Sender |
info |
Before each HTTP call |
event, key (idempotency key), url
|
DeliveryJob |
info |
Successful delivery |
event, delivery_id, subscription_id, status, duration
|
DeliveryJob |
warn |
Retryable failure |
event, delivery_id, subscription_id, status, attempt, next_retry_at
|
DeliveryJob |
error |
Permanent failure (all retries exhausted) |
event, delivery_id, subscription_id, status, attempts
|
DeliveryJob |
warn |
Circuit breaker tripped |
subscription_id, consecutive_failures
|
Example lines:
[RailsWebhookOutbox] attempt event=order.created key=550e8400-e29b-41d4-a716-446655440000 url=https://example.com/webhooks
[RailsWebhookOutbox] delivered event=order.created delivery_id=1 subscription_id=1 status=200 duration=45ms
[RailsWebhookOutbox] retry event=order.created delivery_id=1 subscription_id=1 status=503 attempt=1 next_retry_at=2026-07-01T00:00:13Z
[RailsWebhookOutbox] failed event=order.created delivery_id=1 subscription_id=1 status=503 attempts=3
[RailsWebhookOutbox] circuit_breaker_tripped subscription_id=1 consecutive_failures=10
No configuration is required — logging is always on and respects your application's log level.
Back to top
HTTP Request Format
Each webhook delivery is an HTTP POST to the subscription URL with the following headers and body:
POST https://example.com/webhooks
Content-Type: application/json
X-Webhook-Signature: sha256=a1b2c3d4...
X-Webhook-Event: order.created
X-Webhook-Delivery: 550e8400-e29b-41d4-a716-446655440000
X-Webhook-Timestamp: 1719100800
{
"event": "order.created",
"delivered_at": "2026-06-26T10:00:00Z",
"data": { "id": 42, "total": "99.00" }
}
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.
Non-2xx responses raise RailsWebhookOutbox::DeliveryError, which carries response_code and response_body for logging and retry decisions.
Back to top
HMAC Signing
Every outgoing request includes an X-Webhook-Signature header (configurable) containing an HMAC digest of the request body:
X-Webhook-Signature: sha256=a1b2c3d4...
Subscribers can verify the signature. The header may contain more than one comma-separated
algorithm=digest pair while a subscription's secret is rotating, so check
each one and accept the request if any match:
expected = RailsWebhookOutbox::Signature.sign(raw_body, subscription.secret, :sha256)
signatures = request.headers["X-Webhook-Signature"].to_s.split(",")
valid = signatures.any? { |sig| Rack::Utils.secure_compare(sig, "sha256=#{expected}") }You can also call the primitives directly:
# Produce a hex digest with an explicit algorithm
RailsWebhookOutbox::Signature.sign(payload, secret, :sha256)
# => "a1b2c3d4..."
# Produce the full header value using the configured algorithm
RailsWebhookOutbox::Signature.header_value(payload, secret)
# => "sha256=a1b2c3d4..."header_value also accepts an array of secrets, signing the payload with each one and joining the
results with a comma — this is how dual-secret signing works during secret rotation.
Back to top
Secret Rotation
Rotate a subscription's HMAC secret without dropping any webhook deliveries:
sub.rotate_secret!This generates a new secret and moves the old one to previous_secret, valid until
previous_secret_expires_at (set from config.secret_rotation_grace_period, default 24 hours).
Pass grace_period: to override it for a single rotation:
sub.rotate_secret!(grace_period: 3.days)Calling rotate_secret! again while a previous secret is still within its grace period raises
RailsWebhookOutbox::SecretRotationError, since the schema only holds one previous secret — doing
so would silently invalidate a secret subscribers may not have finished transitioning to yet. Pass
force: true to rotate anyway and discard it immediately.
While previous_secret is still active, every outgoing request is signed with both secrets —
X-Webhook-Signature carries a comma-separated list of algorithm=digest pairs:
X-Webhook-Signature: sha256=<new-secret-digest>,sha256=<previous-secret-digest>
See HMAC Signing for how subscribers should verify a header that may contain more than one signature.
Back to top
Circuit Breaker
Each Subscription tracks consecutive_failures — how many deliveries in a row have permanently
failed (all retries exhausted). A successful delivery resets it to zero.
Once consecutive_failures reaches config.circuit_breaker_threshold (default 10), the
subscription is automatically disabled (active: false) so a persistently failing endpoint stops
being hammered — deliveries already in flight are skipped rather than retried further once their
subscription is disabled — and webhook.circuit_breaker_tripped.rails_webhook_outbox is published:
ActiveSupport::Notifications.subscribe("webhook.circuit_breaker_tripped.rails_webhook_outbox") do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
Sentry.capture_message("Webhook subscription auto-disabled",
extra: event.payload.slice(:subscription_id, :consecutive_failures))
endSet config.circuit_breaker_threshold to nil or 0 to disable auto-disabling entirely. Retryable
(non-final) failures don't count towards the threshold — only a delivery that has exhausted all
retries counts as one consecutive failure. Re-enable a tripped subscription with
sub.update!(active: true); consecutive_failures resets to zero immediately on reactivation, so a
single subsequent failure won't instantly re-trip the breaker.
Back to top
Rake Tasks
bin/rails webhook_outbox:retry_failed # re-enqueue all failed deliveries for retry
bin/rails webhook_outbox:list_subscriptions # list subscriptions with status, events, and failure count
bin/rails webhook_outbox:cleanup[7] # delete delivered/failed deliveries older than N daysretry_failed resets each failed delivery to pending and enqueues a DeliveryJob; deliveries
whose subscription has since been disabled are skipped by the job itself, not by the task.
cleanup[days] requires a positive integer argument and only removes deliveries in a terminal
state (delivered or failed) — pending deliveries are never deleted.
Back to top
Usage
Include RailsWebhookOutbox::Dispatchable in any ActiveRecord model to automatically dispatch webhooks on lifecycle events:
class Order < ApplicationRecord
include RailsWebhookOutbox::Dispatchable
dispatches_webhook "order.created", on: :create
dispatches_webhook "order.updated", on: :update
dispatches_webhook "order.cancelled", on: :update,
if: -> { cancelled_at_previously_changed? }
endWhen 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.
The if: option accepts a lambda that is evaluated in the context of the model instance, so any attribute or method is available.
Payload
By default the full record is sent as the webhook payload via as_json. Override webhook_payload to control exactly what is sent:
class Order < ApplicationRecord
include RailsWebhookOutbox::Dispatchable
dispatches_webhook "order.created", on: :create
def webhook_payload
{ id:, total: total.to_s, items: line_items.count }
end
endBack to top
Manual Dispatch
Dispatch a webhook event outside of model callbacks using RailsWebhookOutbox.dispatch:
RailsWebhookOutbox.dispatch("payment.completed", {
id: payment.id,
amount: payment.amount,
currency: payment.currency
})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.
You can also validate an event name directly without dispatching:
RailsWebhookOutbox.validate_event!("payment.completed")
# raises ArgumentError if the event is not in config.eventsThis is the same delivery pipeline used by Dispatchable callbacks, so retries, HMAC signing, and delivery logging all apply.
Back to top
Testing
Enable test mode in your rails_helper.rb to suppress HTTP calls and DB writes during specs:
require "rails_webhook_outbox/rspec_matchers"
RailsWebhookOutbox.configure { |c| c.test_mode = true }
RSpec.configure do |config|
config.before { RailsWebhookOutbox::Testing.clear_deliveries! }
endWhen 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:
expect { order.save! }.to dispatch_webhook("order.created")
expect { order.save! }.to dispatch_webhook("order.created").with_payload({ id: order.id })
expect { order.save! }.not_to dispatch_webhook("order.updated")Inspect captured events directly if needed:
RailsWebhookOutbox::Testing.deliveries
# => [{ event: "order.created", payload: { "id" => 1, ... } }]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.
Back to top
Development
$ bundle install
$ bundle exec rspec
$ bin/rubocopDummy App
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.
Setup
From the repo root:
$ cd spec/dummy
$ bin/setup # installs deps, runs db:prepare, then starts the serverOr set up without starting the server:
$ bin/rails db:create db:migrate
$ bin/rails db:schema:load:queue
$ bin/rails db:seedStart the server
bin/dev runs the web server and solid_queue worker together via foreman:
$ bin/devThe API is available at http://localhost:3000.
Try it with curl
Create an order (fires order.created):
curl -X POST http://localhost:3000/orders \
-H "Content-Type: application/json" \
-d '{"order": {"title": "Widget Pack", "total": "49.99", "status": "pending"}}'Update an order (fires order.updated):
curl -X PATCH http://localhost:3000/orders/1 \
-H "Content-Type: application/json" \
-d '{"order": {"status": "confirmed"}}'Cancel an order (fires order.cancelled):
curl -X PATCH http://localhost:3000/orders/1 \
-H "Content-Type: application/json" \
-d '{"order": {"status": "cancelled", "cancelled_at": "2026-06-29T12:00:00Z"}}'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:
RailsWebhookOutbox::Subscription.first.update!(url: "https://webhook.site/your-id")Back to top
Contributing
Bug reports and pull requests are welcome on GitHub.
Back to top
License
The gem is available as open source under the terms of the MIT License.
Back to top