How to Handle Webhooks in Ruby on Rails

Creating a Webhooks Controller

In Rails, webhook endpoints are typically handled by a dedicated controller. Skip CSRF verification since webhook requests come from external services, not from your application's forms:

ruby
# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

def stripe payload = request.body.read sig_header = request.env['HTTP_STRIPE_SIGNATURE'] event = nil

begin event = Stripe::Webhook.construct_event( payload, sig_header, ENV['STRIPE_WEBHOOK_SECRET'] ) rescue JSON::ParserError head :bad_request and return rescue Stripe::SignatureVerificationError head :bad_request and return end

case event.type when 'payment_intent.succeeded' handle_payment_succeeded(event.data.object) when 'customer.subscription.deleted' handle_subscription_canceled(event.data.object) end

head :ok end end ```

The skip_before_action :verify_authenticity_token is essential — without it, Rails rejects POST requests that do not include a CSRF token. Use request.body.read to get the raw body for signature verification.

Routing Configuration

Add routes for your webhook endpoints in config/routes.rb:

ruby
# config/routes.rb
Rails.application.routes.draw do
  post '/webhooks/stripe', to: 'webhooks#stripe'
  post '/webhooks/github', to: 'webhooks#github'
  post '/webhooks/shopify', to: 'webhooks#shopify'
end

For a cleaner organization with many webhook providers, use a namespace:

ruby
namespace :webhooks do
  post 'stripe', to: 'stripe#create'
  post 'github', to: 'github#create'
  post 'shopify', to: 'shopify#create'
end

This creates Webhooks::StripeController, Webhooks::GithubController, etc., keeping each provider's handling logic in its own controller.

Signature Verification

For providers that do not have a Ruby gem with built-in verification, implement HMAC verification manually:

ruby
class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

def github payload = request.body.read signature = request.env['HTTP_X_HUB_SIGNATURE_256']

unless valid_signature?(payload, signature) head :unauthorized and return end

event = request.env['HTTP_X_GITHUB_EVENT'] data = JSON.parse(payload)

case event when 'push' handle_push(data) when 'pull_request' handle_pull_request(data) end

head :ok end

private

def valid_signature?(payload, signature) return false unless signature

expected = 'sha256=' + OpenSSL::HMAC.hexdigest( 'SHA256', ENV['GITHUB_WEBHOOK_SECRET'], payload )

ActiveSupport::SecurityUtils.secure_compare(expected, signature) end end ```

Use ActiveSupport::SecurityUtils.secure_compare for timing-safe string comparison. This is Rails' built-in equivalent of crypto.timingSafeEqual in Node.js.

Background Job Processing

Use Active Job (with Sidekiq, Delayed Job, or another backend) for async processing:

ruby
# app/jobs/process_webhook_job.rb
class ProcessWebhookJob < ApplicationJob
  queue_as :webhooks

def perform(event_type, event_data) case event_type when 'payment_intent.succeeded' order = Order.find_by(stripe_payment_intent_id: event_data['id']) order&.mark_as_paid! when 'customer.subscription.deleted' sub = Subscription.find_by(stripe_id: event_data['id']) sub&.cancel! end end end

# In the controller def stripe # ... signature verification ...

# Idempotency check return head :ok if ProcessedEvent.exists?(event_id: event.id) ProcessedEvent.create!(event_id: event.id)

# Enqueue for processing ProcessWebhookJob.perform_later(event.type, event.data.object.to_h)

head :ok end ```

This responds to the webhook immediately and processes the event in a background worker. The idempotency check using ProcessedEvent prevents duplicate processing.

Testing with ReqPour and RSpec

During development, relay webhooks to your Rails server:

bash
rails server -p 3000
# In another terminal:
npx reqpour relay --to http://localhost:3000

Write RSpec tests using captured webhook payloads:

ruby
RSpec.describe WebhooksController, type: :controller do
  describe 'POST #stripe' do
    let(:payload) do
      {
        id: 'evt_test_123',
        type: 'payment_intent.succeeded',
        data: {
          object: { id: 'pi_456', amount: 2500 }
        }
      }.to_json
    end

it 'processes payment events' do allow(Stripe::Webhook).to receive(:construct_event) .and_return(Stripe::Event.construct_from(JSON.parse(payload)))

post :stripe, body: payload expect(response).to have_http_status(:ok) end end end ```

Capture real webhook payloads from ReqPour and store them as fixtures in spec/fixtures/webhooks/ for use in your test suite.

Get started with ReqPour

Catch, inspect, and relay webhooks to localhost. Free to start, $3/mo for Pro.