Overview
Ruby on Rails' convention-over-configuration approach makes webhook handling clean and maintainable. Use controller actions for the endpoint, Active Job for background processing, and Rails credentials for secure secret management.
What you'll build:
- A webhook controller with HMAC signature verification
- Active Job background worker for async processing
- HTTP callback using
Net::HTTPor Faraday - Rails credentials integration for API key management
Quick start
Install dependencies
Bash
rails new my-ai-app --apicd my-ai-appAdd to your Gemfile:
RUBY
1gem "faraday"Bash
bundle installConfiguration
Bash
# Store secrets with Rails credentialsEDITOR="code --wait" rails credentials:editYAML
1# config/credentials.yml.enc2modelriver:3 api_key: mr_live_YOUR_API_KEY4 webhook_secret: your_webhook_secretWebhook controller
RUBY
1# app/controllers/webhooks/modelriver_controller.rb2module Webhooks3 class ModelriverController < ApplicationController4 skip_before_action :verify_authenticity_token5 before_action :verify_signature6 7 def create8 payload = JSON.parse(request.body.read)9 type = payload["type"]10 callback_url = payload["callback_url"]11 12 # Handle event-driven workflow13 if type == "task.ai_generated" && callback_url.present?14 ProcessAiWebhookJob.perform_later(15 event: payload["event"],16 ai_response: payload["ai_response"],17 callback_url: callback_url,18 customer_data: payload["customer_data"] || {}19 )20 21 render json: { received: true }, status: :ok22 return23 end24 25 # Standard webhook26 Rails.logger.info("Standard webhook received: #{type}")27 render json: { received: true }, status: :ok28 end29 30 private31 32 def verify_signature33 signature = request.headers["mr-signature"].to_s34 raw_body = request.body.read35 request.body.rewind36 37 secret = Rails.application.credentials.dig(:modelriver, :webhook_secret)38 expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)39 40 unless ActiveSupport::SecurityUtils.secure_compare(expected, signature)41 render json: { error: "Invalid signature" }, status: :unauthorized42 end43 end44 end45endRoutes
RUBY
1# config/routes.rb2Rails.application.routes.draw do3 namespace :webhooks do4 post "modelriver", to: "modelriver#create"5 end6endActive Job
RUBY
1# app/jobs/process_ai_webhook_job.rb2class ProcessAiWebhookJob < ApplicationJob3 queue_as :default4 retry_on StandardError, wait: 10.seconds, attempts: 35 6 def perform(event:, ai_response:, callback_url:, customer_data:)7 enriched_data = (ai_response["data"] || {}).dup8 9 # Your custom business logic10 case event11 when "content_ready"12 content = Content.create!(13 title: enriched_data["title"],14 body: enriched_data["description"],15 category: customer_data["category"] || "general",16 source: "modelriver"17 )18 enriched_data["id"] = content.id19 enriched_data["slug"] = content.slug20 enriched_data["saved_at"] = Time.current.iso860121 22 when "entities_extracted"23 entities = enriched_data["entities"] || []24 entities.each do |entity|25 Entity.create!(26 name: entity["name"],27 entity_type: entity["type"],28 source_id: customer_data["document_id"]29 )30 end31 enriched_data["entities_saved"] = entities.length32 end33 34 # Call back to ModelRiver35 api_key = Rails.application.credentials.dig(:modelriver, :api_key)36 37 conn = Faraday.new do |f|38 f.request :json39 f.response :raise_error40 f.options.timeout = 1041 end42 43 conn.post(callback_url) do |req|44 req.headers["Authorization"] = "Bearer #{api_key}"45 req.headers["Content-Type"] = "application/json"46 req.body = {47 data: enriched_data,48 task_id: "rails_#{event}_#{Time.current.to_i}",49 metadata: {50 processed_by: "rails",51 processed_at: Time.current.iso860152 }53 }.to_json54 end55 56 Rails.logger.info("✅ Callback sent for event: #{event}")57 58 rescue Faraday::Error => e59 Rails.logger.error("❌ Callback failed: #{e.message}")60 61 # Send error callback62 Faraday.post(callback_url) do |req|63 req.headers["Authorization"] = "Bearer #{api_key}"64 req.headers["Content-Type"] = "application/json"65 req.body = {66 error: "processing_failed",67 message: e.message68 }.to_json69 end70 71 raise72 end73endTriggering async requests
RUBY
1# app/services/modelriver_service.rb2class ModelriverService3 BASE_URL = "https://api.modelriver.com"4 5 def initialize6 @api_key = Rails.application.credentials.dig(:modelriver, :api_key)7 @conn = Faraday.new(url: BASE_URL) do |f|8 f.request :json9 f.response :json10 f.response :raise_error11 f.options.timeout = 1012 end13 end14 15 def trigger_async(workflow:, prompt:, metadata: {})16 response = @conn.post("/v1/ai/async") do |req|17 req.headers["Authorization"] = "Bearer #{@api_key}"18 req.body = {19 workflow: workflow,20 messages: [{ role: "user", content: prompt }],21 metadata: metadata22 }23 end24 25 response.body26 end27end28 29# Usage in a controller30class AiController < ApplicationController31 def generate32 service = ModelriverService.new33 result = service.trigger_async(34 workflow: "content_generator",35 prompt: params[:prompt],36 metadata: { user_id: current_user.id }37 )38 39 render json: {40 channel_id: result["channel_id"],41 ws_token: result["ws_token"],42 websocket_channel: result["websocket_channel"]43 }44 end45endBest practices
- Use Active Job: Never block the webhook response; dispatch to a background queue.
- Use Rails credentials: Store API keys and webhook secrets securely.
- Use
secure_compare: Prevent timing attacks on signature verification. - Rewind request body: Call
request.body.rewindafter reading raw body for signature verification. - Use Faraday: Consistent HTTP client with retry and timeout support.
Next steps
- Phoenix event-driven guide: Elixir alternative
- Webhooks reference: Retry policies and delivery monitoring
- Event-driven AI overview: Architecture and flow