ActionWebhook
ActionWebhook is a Rails-friendly framework for delivering structured webhooks with the familiarity of ActionMailer. It provides a clean, testable, and reliable way to send webhooks to external services from any Rails application.
Features
- ActionMailer-inspired API — familiar patterns for Rails developers
- ERB template support with safe JSON serialisation via
json_escape - HMAC-SHA256 payload signing, configurable globally or per endpoint
- Smart retry logic — only retries failed URLs, not successful ones
- ActiveJob integration — queue webhooks using your existing job infrastructure
- Lifecycle callbacks — hook into delivery and retry-exhaustion events
- Multiple endpoints — fan out to many URLs in a single call
- Two header formats — hash or database-friendly key/value array
- Comprehensive logging and optional request debugging
Installation
Add to your Gemfile:
gem 'action_webhook'Then run:
bundle installQuick Start
1. Define a webhook class
# app/webhooks/user_webhook.rb
class UserWebhook < ActionWebhook::Base
def user_created
@user = params[:user]
@timestamp = Time.current
endpoints = [
{
url: 'https://api.example.com/webhooks',
headers: { 'Authorization' => 'Bearer your-token' }
}
]
deliver(endpoints)
end
end2. Create the payload template
Variables set on the webhook instance are available as @variable in the template. Use json_escape to safely embed any Ruby value — it handles quoting, escaping, and type conversion automatically.
<%# app/webhooks/user_webhook/user_created.json.erb %>
{
"event": "user.created",
"timestamp": <%= json_escape(@timestamp.iso8601) %>,
"data": {
"user": {
"id": <%= json_escape(@user.id) %>,
"email": <%= json_escape(@user.email) %>,
"name": <%= json_escape(@user.name) %>,
"created_at": <%= json_escape(@user.created_at.iso8601) %>
}
}
}json_escape produces a valid JSON literal for any value:
| Ruby value | Output |
|---|---|
"Jane \"Doe\"" |
"Jane \"Doe\"" |
42 |
42 |
nil |
null |
true |
true |
3. Trigger the webhook
# Deliver immediately
UserWebhook.user_created(user: @user).deliver_now
# Deliver in the background (recommended)
UserWebhook.user_created(user: @user).deliver_later
# Deliver to a specific queue
UserWebhook.user_created(user: @user).deliver_later(queue: 'webhooks')
# Deliver after a delay
UserWebhook.user_created(user: @user).deliver_later(wait: 5.minutes)Payload Signing
ActionWebhook can sign every payload with an HMAC-SHA256 signature. Receivers can verify this signature to confirm the request is authentic and the payload has not been tampered with — the same mechanism used by GitHub, Stripe, and Shopify.
Global signing secret
class UserWebhook < ActionWebhook::Base
self.signing_secret = Rails.application.credentials.webhook_signing_secret
self.signature_header = 'X-Webhook-Signature' # default, can be omitted
endEvery outgoing request will include an X-Webhook-Signature header:
X-Webhook-Signature: sha256=a3f1c2...
Per-endpoint signing secret
When different subscribers use different secrets, pass signing_secret on each endpoint:
endpoints = WebhookSubscription.where(event: 'user.created').map do |sub|
{
url: sub.url,
headers: { 'Authorization' => "Bearer #{sub.token}" },
signing_secret: sub.signing_secret
}
end
deliver(endpoints)A per-endpoint secret takes precedence over the class-level secret.
Custom signature header name
class UserWebhook < ActionWebhook::Base
self.signing_secret = Rails.application.credentials.webhook_signing_secret
self.signature_header = 'X-Hub-Signature-256'
endVerifying signatures on the receiver side
def verify_webhook_signature(request)
secret = Rails.application.credentials.webhook_signing_secret
body = request.raw_post
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
received = request.headers['X-Webhook-Signature']
ActiveSupport::SecurityUtils.secure_compare(expected, received)
endRetry Logic
ActionWebhook retries only the endpoints that fail, leaving successful deliveries untouched.
class PaymentWebhook < ActionWebhook::Base
self.max_retries = 5
self.retry_delay = 30.seconds
self.retry_backoff = :exponential # :exponential, :linear, or :fixed
self.retry_jitter = 5.seconds # randomises delay to prevent thundering herd
def payment_completed
@payment = params[:payment]
deliver([
{ url: 'https://accounting.example.com/webhooks' },
{ url: 'https://analytics.example.com/webhooks' },
{ url: 'https://notifications.example.com/webhooks' }
])
# If only analytics.example.com fails, only that URL is retried.
end
endCallbacks
class OrderWebhook < ActionWebhook::Base
after_deliver :handle_successful_deliveries
after_retries_exhausted :handle_permanent_failures
def order_created
@order = params[:order]
deliver(webhook_endpoints)
end
private
def handle_successful_deliveries(responses)
responses.each do |r|
Rails.logger.info "Delivered to #{r[:url]} (HTTP #{r[:status]})"
end
end
def handle_permanent_failures(responses)
responses.each do |r|
Rails.logger.error "Permanently failed for #{r[:url]} after #{r[:attempt]} attempts"
AdminMailer.webhook_failure(@order.id, r).deliver_later
end
end
endHeader Formats
ActionWebhook accepts headers in two formats.
Hash format — the standard approach:
endpoints = [
{
url: 'https://api.example.com/webhooks',
headers: {
'Authorization' => 'Bearer token123',
'X-API-Version' => '2024-01-01'
}
}
]Array format — useful when headers are stored in a database:
endpoints = [
{
url: 'https://api.example.com/webhooks',
headers: [
{ 'key' => 'Authorization', 'value' => 'Bearer token123' },
{ 'key' => 'X-API-Version', 'value' => '2024-01-01' }
]
}
]Both string keys ('key') and symbol keys (:key) are accepted in the array format.
Debug Logging
Enable detailed request and header logging per webhook class:
class MyWebhook < ActionWebhook::Base
self.debug_headers = true
endThis writes to Rails.logger:
ActionWebhook Headers Debug:
Default headers: {}
Processed headers: {"Authorization"=>"Bearer token123"}
Final headers: {"Authorization"=>"Bearer token123", "Content-Type"=>"application/json"}
ActionWebhook Request Debug:
URL: https://api.example.com/webhooks
Headers: {"Authorization"=>"Bearer token123", "Content-Type"=>"application/json"}
Payload size: 156 bytes
Testing
Set the delivery method to :test to capture webhooks without making HTTP requests:
class UserWebhookTest < ActiveSupport::TestCase
setup do
UserWebhook.delivery_method = :test
ActionWebhook::Base.deliveries.clear
end
test "delivers user_created webhook" do
user = users(:john)
UserWebhook.user_created(user: user).deliver_now
assert_equal 1, ActionWebhook::Base.deliveries.size
webhook = ActionWebhook::Base.deliveries.first
assert_equal :user_created, webhook.action_name
end
endDocumentation
- Installation Guide
- Quick Start
- Configuration Reference
- Basic Usage
- Template System
- Queue Management
- Retry Logic
- Callbacks
- Error Handling
- Testing Guide
- API Reference
- Examples
Requirements
- Ruby >= 3.1.0
- ActiveJob >= 6.0 (Rails 6.0+)
Contributing
- Fork the repository
- Create a feature branch (
git checkout -b feature/my-feature) - Commit your changes (
git commit -m 'Add my feature') - Push to the branch (
git push origin feature/my-feature) - Open a Pull Request
See CONTRIBUTING.md for more detail.
License
Available as open source under the MIT License.