0.0
The project is in a healthy, maintained state
A Rails library for triggering webhooks. Inspired by ActionMailer from Rails
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 0.9

Runtime

>= 7.0, < 9.0
~> 1.0
~> 0.24
 Project Readme

ActionWebhook

Gem Version CI Release License: MIT

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 install

Quick 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
end

2. 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
end

Every 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'
end

Verifying 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)
end

Retry 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
end

Callbacks

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
end

Header 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
end

This 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
end

Documentation

Requirements

  • Ruby >= 3.1.0
  • ActiveJob >= 6.0 (Rails 6.0+)

Contributing

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/my-feature)
  3. Commit your changes (git commit -m 'Add my feature')
  4. Push to the branch (git push origin feature/my-feature)
  5. Open a Pull Request

See CONTRIBUTING.md for more detail.

License

Available as open source under the MIT License.

Support