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:
# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_tokendef 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:
# 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'
endFor a cleaner organization with many webhook providers, use a namespace:
namespace :webhooks do
post 'stripe', to: 'stripe#create'
post 'github', to: 'github#create'
post 'shopify', to: 'shopify#create'
endThis 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:
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_tokendef 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:
# app/jobs/process_webhook_job.rb
class ProcessWebhookJob < ApplicationJob
queue_as :webhooksdef 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:
rails server -p 3000
# In another terminal:
npx reqpour relay --to http://localhost:3000Write RSpec tests using captured webhook payloads:
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
endit '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.
Related
Get started with ReqPour
Catch, inspect, and relay webhooks to localhost. Free to start, $3/mo for Pro.