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
- Contributing
- License
Installation
Add this line to your application's Gemfile:
gem "rails_webhook_outbox"
And then execute:
$ bundle install
Run the install generator:
$ rails generate rails_webhook_outbox:install
$ rails db:migrate
Configuration
# 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
end
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.
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).
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") # => false
Use the active scope to find enabled subscriptions:
RailsWebhookOutbox::Subscription.active
Disable a subscription by setting active: false:
sub.update!(active: false)
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? # => true
Filter deliveries by status:
RailsWebhookOutbox::Delivery.retryable # pending — awaiting delivery or retry
RailsWebhookOutbox::Delivery.delivered # successfully delivered
RailsWebhookOutbox::Delivery.failed # exhausted all retries
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
end
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:
| 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)
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.("Webhook permanently failed", extra: event.payload)
end
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.
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.
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.
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.
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.("Webhook subscription auto-disabled",
extra: event.payload.slice(:subscription_id, :consecutive_failures))
end
Set 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.
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 days
retry_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.
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? }
end
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.
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
end
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.events
This is the same delivery pipeline used by Dispatchable callbacks, so retries, HMAC signing, and delivery logging all apply.
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! }
end
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:
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.
Development
$ bundle install
$ bundle exec rspec
$ bin/rubocop
Dummy 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 server
Or set up without starting the server:
$ bin/rails db:create db:migrate
$ bin/rails db:schema:load:queue
$ bin/rails db:seed
Start the server
bin/dev runs the web server and solid_queue worker together via foreman:
$ bin/dev
The 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")
Contributing
Bug reports and pull requests are welcome on GitHub.
License
The gem is available as open source under the terms of the MIT License.