Documentation

Event-driven AI with Rails

Receive AI webhooks in Rails controllers, process with Active Job, and call back to ModelRiver: idiomatic Ruby webhook handling.

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::HTTP or Faraday
  • Rails credentials integration for API key management

Quick start

Install dependencies

Bash
rails new my-ai-app --api
cd my-ai-app

Add to your Gemfile:

RUBY
1gem "faraday"
Bash
bundle install

Configuration

Bash
# Store secrets with Rails credentials
EDITOR="code --wait" rails credentials:edit
YAML
1# config/credentials.yml.enc
2modelriver:
3 api_key: mr_live_YOUR_API_KEY
4 webhook_secret: your_webhook_secret

Webhook controller

RUBY
1# app/controllers/webhooks/modelriver_controller.rb
2module Webhooks
3 class ModelriverController < ApplicationController
4 skip_before_action :verify_authenticity_token
5 before_action :verify_signature
6 
7 def create
8 payload = JSON.parse(request.body.read)
9 type = payload["type"]
10 callback_url = payload["callback_url"]
11 
12 # Handle event-driven workflow
13 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: :ok
22 return
23 end
24 
25 # Standard webhook
26 Rails.logger.info("Standard webhook received: #{type}")
27 render json: { received: true }, status: :ok
28 end
29 
30 private
31 
32 def verify_signature
33 signature = request.headers["mr-signature"].to_s
34 raw_body = request.body.read
35 request.body.rewind
36 
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: :unauthorized
42 end
43 end
44 end
45end

Routes

RUBY
1# config/routes.rb
2Rails.application.routes.draw do
3 namespace :webhooks do
4 post "modelriver", to: "modelriver#create"
5 end
6end

Active Job

RUBY
1# app/jobs/process_ai_webhook_job.rb
2class ProcessAiWebhookJob < ApplicationJob
3 queue_as :default
4 retry_on StandardError, wait: 10.seconds, attempts: 3
5 
6 def perform(event:, ai_response:, callback_url:, customer_data:)
7 enriched_data = (ai_response["data"] || {}).dup
8 
9 # Your custom business logic
10 case event
11 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.id
19 enriched_data["slug"] = content.slug
20 enriched_data["saved_at"] = Time.current.iso8601
21 
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 end
31 enriched_data["entities_saved"] = entities.length
32 end
33 
34 # Call back to ModelRiver
35 api_key = Rails.application.credentials.dig(:modelriver, :api_key)
36 
37 conn = Faraday.new do |f|
38 f.request :json
39 f.response :raise_error
40 f.options.timeout = 10
41 end
42 
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.iso8601
52 }
53 }.to_json
54 end
55 
56 Rails.logger.info("✅ Callback sent for event: #{event}")
57 
58 rescue Faraday::Error => e
59 Rails.logger.error("❌ Callback failed: #{e.message}")
60 
61 # Send error callback
62 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.message
68 }.to_json
69 end
70 
71 raise
72 end
73end

Triggering async requests

RUBY
1# app/services/modelriver_service.rb
2class ModelriverService
3 BASE_URL = "https://api.modelriver.com"
4 
5 def initialize
6 @api_key = Rails.application.credentials.dig(:modelriver, :api_key)
7 @conn = Faraday.new(url: BASE_URL) do |f|
8 f.request :json
9 f.response :json
10 f.response :raise_error
11 f.options.timeout = 10
12 end
13 end
14 
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: metadata
22 }
23 end
24 
25 response.body
26 end
27end
28 
29# Usage in a controller
30class AiController < ApplicationController
31 def generate
32 service = ModelriverService.new
33 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 end
45end

Best practices

  1. Use Active Job: Never block the webhook response; dispatch to a background queue.
  2. Use Rails credentials: Store API keys and webhook secrets securely.
  3. Use secure_compare: Prevent timing attacks on signature verification.
  4. Rewind request body: Call request.body.rewind after reading raw body for signature verification.
  5. Use Faraday: Consistent HTTP client with retry and timeout support.

Next steps