Ruby SDK
Ruby on Rails Integration
Complete integration guide for Ruby on Rails applications using modern patterns including Service Objects, Form Objects, Concerns, and comprehensive error handling.
This example follows Rails 7/8 best practices including Zeitwerk autoloading, ActiveJob with Sidekiq, and service-oriented architecture patterns.
Project Structure
app/
├── controllers/api/v1/
│ ├── messages_controller.rb
│ └── webhooks_controller.rb
├── services/sent_dm/
│ ├── base_service.rb
│ ├── send_message_service.rb
│ └── send_welcome_service.rb
├── concerns/
│ ├── models/sent_dm_messageable.rb
│ └── controllers/webhook_verifiable.rb
├── jobs/sent_dm/
│ ├── send_message_job.rb
│ └── process_webhook_job.rb
└── models/message.rbEnvironment Variables
| Variable | Required | Description |
|---|---|---|
SENT_DM_API_KEY | Yes | Your Sent DM API key for authentication |
SENT_DM_WEBHOOK_SECRET | Yes (production) | Secret for verifying webhook signatures |
SENT_BASE_URL | No | API base URL (default: https://api.sent.dm) |
SENT_TIMEOUT | No | Request timeout in seconds (default: 30) |
SENT_RETRY_ATTEMPTS | No | Number of retry attempts for failed requests (default: 3) |
REDIS_URL | No | Redis connection URL for Sidekiq (default: redis://localhost:6379/0) |
Example .env file
# Required
SENT_DM_API_KEY=your_api_key_here
# Required for production
SENT_DM_WEBHOOK_SECRET=your_webhook_secret_here
# Optional
SENT_BASE_URL=https://api.sent.dm
SENT_TIMEOUT=30
SENT_RETRY_ATTEMPTS=3
REDIS_URL=redis://localhost:6379/0Configuration
Initializer
# config/initializers/sentdm.rb
module SentDmConfig
class ConfigurationError < StandardError; end
class << self
def configure
validate_environment!
SentDM.configure do |config|
config.api_key = api_key
config.webhook_secret = webhook_secret
config.base_url = base_url
config.timeout = timeout
config.retry_attempts = retry_attempts
end
end
def client
@client ||= Sentdm::Client.new(api_key)
end
private
def validate_environment!
raise ConfigurationError, 'SENT_DM_API_KEY required' if api_key.blank?
raise ConfigurationError, 'SENT_DM_WEBHOOK_SECRET required in production' if webhook_secret.blank? && Rails.env.production?
end
def api_key = ENV.fetch('SENT_DM_API_KEY', nil)
def webhook_secret = ENV.fetch('SENT_DM_WEBHOOK_SECRET', nil)
def base_url = ENV.fetch('SENT_BASE_URL', 'https://api.sent.dm')
def timeout = ENV.fetch('SENT_TIMEOUT', '30').to_i
def retry_attempts = ENV.fetch('SENT_RETRY_ATTEMPTS', '3').to_i
end
end
SentDmConfig.configureBase Classes
# app/services/application_service.rb
class ApplicationService
include ActiveModel::Validations
class ServiceError < StandardError; end
class ValidationError < ServiceError; end
def self.call(...) = new(...).call
def call = raise NotImplementedError
def success(data = nil) = ServiceResult.success(data)
def failure(errors, data = nil) = ServiceResult.failure(errors, data)
def validate! = raise(ValidationError, errors.full_messages.join(', ')) unless valid?
end
class ServiceResult
attr_reader :data, :errors
def initialize(success:, data: nil, errors: []) = (@success, @data, @errors = success, data, Array(errors))
def self.success(data = nil) = new(success: true, data: data)
def self.failure(errors, data = nil) = new(success: false, errors: errors, data: data)
def success? = @success
def failure? = !success?
endService Objects
Base Service
# app/services/sent_dm/base_service.rb
module SentDm
class BaseService < ApplicationService
protected
def client = @client ||= SentDmConfig.client
def handle_api_error(error)
Rails.logger.error "[SentDM] API Error: #{error.message}"
case error
when SentDM::RateLimitError then failure(:rate_limited, "Rate limit exceeded")
when SentDM::AuthenticationError then failure(:unauthorized, "Authentication failed")
when SentDM::ValidationError then failure(:validation_error, error.message)
else failure(:api_error, "Unexpected error")
end
end
def with_transaction
ActiveRecord::Base.transaction { yield }
rescue ActiveRecord::RecordInvalid => e
failure(:database_error, e.message)
end
end
endSend Message Service
# app/services/sent_dm/send_message_service.rb
module SentDm
class SendMessageService < BaseService
attr_reader :phone_number, :template_id, :template_name, :variables, :channels, :user
validates :phone_number, presence: true, format: { with: /\A\+[1-9]\d{1,14}\z/, message: 'must be E.164 format' }
validates :template_id, :channels, presence: true
def initialize(phone_number:, template_id:, template_name: nil, variables: {}, channels: ['whatsapp'], user: nil)
@phone_number, @template_id, @template_name = phone_number, template_id, template_name
@variables, @channels, @user = variables, Array(channels), user
end
def call
validate!
with_transaction do
message = create_message_record!
response = send_to_api
if response.success
update_message_record!(message, response.data)
success(message)
else
mark_failed!(message, response.error)
failure(:api_error, response.error.message)
end
end
rescue SentDM::Error => e
handle_api_error(e)
end
private
def create_message_record!
Message.create!(user: user, phone_number: phone_number, template_id: template_id,
template_name: template_name, variables: variables, channels: channels, status: :pending)
end
def send_to_api
client.messages.send(to: [phone_number], template: { id: template_id, name: template_name, parameters: variables }, channels: channels)
end
def update_message_record!(message, data)
message.update!(external_id: data.id, status: data.status || :queued, sent_at: Time.current)
end
def mark_failed!(message, error)
message.update!(status: :failed, error_message: error.message, failed_at: Time.current)
end
end
endSend Welcome Service
# app/services/sent_dm/send_welcome_service.rb
module SentDm
class SendWelcomeService < BaseService
WELCOME_TEMPLATE_ID = 'welcome-template'.freeze
DEFAULT_CHANNELS = ['whatsapp'].freeze
def initialize(user) = @user = user
def call
return failure(:invalid_user, 'User must have phone number') if @user.phone_number.blank?
SendMessageService.call(phone_number: @user.phone_number, template_id: WELCOME_TEMPLATE_ID,
template_name: 'welcome', variables: { name: @user.first_name },
channels: DEFAULT_CHANNELS, user: @user)
end
end
endConcerns
Webhook Verifiable
# app/controllers/concerns/webhook_verifiable.rb
module WebhookVerifiable
extend ActiveSupport::Concern
class SignatureVerificationError < StandardError; end
included do
skip_before_action :verify_authenticity_token, only: [:webhook]
before_action :verify_webhook_signature, only: [:webhook]
rescue_from SignatureVerificationError, with: :handle_invalid_signature
end
private
def verify_webhook_signature
return if Rails.env.test? || Rails.env.development?
signature = request.headers['X-Webhook-Signature']
payload = request.body.read; request.body.rewind
raise SignatureVerificationError, 'Missing signature' if signature.blank?
raise SignatureVerificationError, 'Invalid signature' unless secure_compare(signature, generate_signature(payload))
end
def generate_signature(payload)
secret = ENV.fetch('SENT_DM_WEBHOOK_SECRET')
Base64.strict_encode64(OpenSSL::HMAC.digest('sha256', secret, payload))
end
def secure_compare(a, b) = ActiveSupport::SecurityUtils.secure_compare(a.to_s, b.to_s)
def handle_invalid_signature = render json: { error: 'Unauthorized' }, status: :unauthorized
endModel Concern
# app/models/concerns/sent_dm_messageable.rb
module SentDmMessageable
extend ActiveSupport::Concern
included do
has_many :sent_messages, class_name: 'Message', dependent: :nullify
after_create :send_welcome_message, if: :should_send_welcome?
end
def send_message(template_id:, variables: {}, channels: ['whatsapp'])
return false unless phone_number.present?
SentDm::SendMessageService.call(phone_number: phone_number, template_id: template_id,
variables: variables.merge(name: respond_to?(:first_name) ? first_name : name),
channels: channels, user: self)
end
def send_welcome_message = SentDm::SendWelcomeService.call(self)
private
def should_send_welcome?
respond_to?(:phone_number) && phone_number.present? && respond_to?(:welcome_sent_at) && welcome_sent_at.nil?
end
endControllers
# app/controllers/api/v1/messages_controller.rb
module Api
module V1
class MessagesController < ApplicationController
def create
result = SentDm::SendMessageService.call(message_params.merge(user: current_user))
result.success? ? render(json: { success: true, data: result.data }) : render(json: { success: false, error: result.errors }, status: :unprocessable_entity)
end
def welcome
user = User.find(params[:user_id])
result = SentDm::SendWelcomeService.call(user)
result.success? ? render(json: { success: true }) : render(json: { success: false, error: result.errors }, status: :unprocessable_entity)
rescue ActiveRecord::RecordNotFound
render json: { error: 'User not found' }, status: :not_found
end
private
def message_params
params.require(:message).permit(:phone_number, :template_id, :template_name, :user_id, variables: {}, channels: [])
end
end
end
end
# app/controllers/api/v1/webhooks_controller.rb
module Api
module V1
class WebhooksController < ApplicationController
include WebhookVerifiable
skip_before_action :verify_authenticity_token
def create
payload = request.body.read; request.body.rewind
signature = request.headers['X-Webhook-Signature']
event = JSON.parse(payload)
result = SentDm::WebhookHandlerService.call(event_type: event['type'], event_data: event['data'])
result.success? ? render(json: { received: true }) : render(json: { error: result.errors }, status: :unprocessable_entity)
rescue JSON::ParserError => e
render json: { error: 'Invalid JSON' }, status: :bad_request
end
end
end
endActiveJob Integration
# app/jobs/sent_dm/send_message_job.rb
module SentDm
class SendMessageJob < ApplicationJob
queue_as :messages
sidekiq_options retry: 5
retry_on SentDM::RateLimitError, wait: :exponentially_longer, attempts: 3
discard_on SentDM::AuthenticationError do |job, error|
Rails.logger.error "[SentDM] Auth failed, discarding job #{job.job_id}"
end
def perform(user_id, template_id, variables = {}, channels = ['whatsapp'])
user = User.find(user_id)
result = SendMessageService.call(phone_number: user.phone_number, template_id: template_id,
variables: variables, channels: channels, user: user)
raise MessageDeliveryError, result.errors.join(', ') if result.failure?
result.data
rescue ActiveRecord::RecordNotFound => e
Rails.logger.error "[SentDM] User #{user_id} not found: #{e.message}"
raise
end
end
end
# app/jobs/sent_dm/process_webhook_job.rb
module SentDm
class ProcessWebhookJob < ApplicationJob
queue_as :webhooks
sidekiq_options retry: 3
def perform(event_type, event_data)
result = WebhookHandlerService.call(event_type: event_type, event_data: OpenStruct.new(event_data))
Rails.logger.info "[SentDM Webhook] Processed #{event_type}: #{result.data&.dig(:message)}"
rescue StandardError => e
Rails.logger.error "[SentDM Webhook] Failed #{event_type}: #{e.message}"
raise
end
end
endModels
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :user, optional: true
enum status: { pending: 'pending', queued: 'queued', sent: 'sent', delivered: 'delivered', read: 'read', failed: 'failed' }
validates :phone_number, presence: true, format: { with: /\A\+[1-9]\d{1,14}\z/ }
validates :template_id, presence: true
validates :external_id, uniqueness: true, allow_nil: true
scope :recent, -> { order(created_at: :desc) }
scope :pending, -> { where(status: [:pending, :queued]) }
scope :failed, -> { where(status: :failed) }
def retry!
return false unless failed?
update!(status: :pending, error_message: nil, failed_at: nil)
SentDm::SendMessageJob.perform_later(user_id, template_id, variables, channels)
end
end
# app/models/user.rb
class User < ApplicationRecord
include SentDmMessageable
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :phone_number, format: { with: /\A\+[1-9]\d{1,14}\z/ }, allow_blank: true
endRoutes
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :messages, only: [:index, :show, :create] do
collection { post :welcome }
end
resources :webhooks, only: [:create]
end
end
post '/webhooks/sent', to: 'api/v1/webhooks#create'
endTesting with RSpec
Spec Helper
# spec/rails_helper.rb
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'
require 'rspec/rails'
require 'factory_bot_rails'
require 'sidekiq/testing'
Sidekiq::Testing.fake!
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
config.use_transactional_fixtures = true
config.before(:each) { Sidekiq::Worker.clear_all }
endFactories
# spec/factories.rb
FactoryBot.define do
factory :user do
sequence(:email) { |n| "user#{n}@example.com" }
first_name { 'John' }
phone_number { '+1234567890' }
end
factory :message do
user
phone_number { '+1234567890' }
template_id { 'welcome-template' }
status { :pending }
channels { ['whatsapp'] }
end
endService Specs
# spec/services/sent_dm/send_message_service_spec.rb
RSpec.describe SentDm::SendMessageService do
let(:user) { create(:user) }
let(:valid_params) do
{ phone_number: '+1234567890', template_id: 'welcome-template', channels: ['whatsapp'], user: user }
end
context 'with valid parameters' do
let(:mock_response) { double('response', success: true, data: double(id: 'msg_123', status: 'queued')) }
before { allow(SentDmConfig.client).to receive(:messages).and_return(double(send: mock_response)) }
it 'creates a message record' do
expect { described_class.call(valid_params) }.to change(Message, :count).by(1)
end
it 'returns a successful result' do
result = described_class.call(valid_params)
expect(result).to be_success
expect(result.data.external_id).to eq('msg_123')
end
end
context 'with invalid phone number' do
it 'returns a failure result' do
result = described_class.call(valid_params.merge(phone_number: 'invalid'))
expect(result).to be_failure
end
end
context 'when API returns an error' do
before { allow(SentDmConfig.client).to receive(:messages).and_raise(SentDM::RateLimitError.new('Rate limited')) }
it 'returns a rate limited error' do
result = described_class.call(valid_params)
expect(result).to be_failure
expect(result.errors).to include(:rate_limited)
end
end
endJob Specs
# spec/jobs/sent_dm/send_message_job_spec.rb
RSpec.describe SentDm::SendMessageJob, type: :job do
let(:user) { create(:user) }
describe '#perform' do
it 'calls SendMessageService' do
expect(SentDm::SendMessageService).to receive(:call).and_return(ServiceResult.success)
described_class.perform_now(user.id, 'welcome-template')
end
it 'raises error on service failure' do
allow(SentDm::SendMessageService).to receive(:call).and_return(ServiceResult.failure([:api_error]))
expect { described_class.perform_now(user.id, 'welcome-template') }.to raise_error(MessageDeliveryError)
end
end
endSidekiq Configuration
# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
config.redis = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') }
config.error_handlers << proc { |ex, ctx| Rails.logger.error "[Sidekiq] #{ex.message}" }
end
Sidekiq.configure_client { |config| config.redis = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') } }# config/sidekiq.yml
:concurrency: 5
:max_retries: 5
:queues:
- [critical, 10]
- [webhooks, 5]
- [messages, 3]
- [mailers, 2]
- [default, 1]Next Steps
- Learn about best practices for production deployments
- Set up webhooks for delivery status updates
- Explore the Ruby SDK reference for advanced features