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.rb

Environment Variables

VariableRequiredDescription
SENT_DM_API_KEYYesYour Sent DM API key for authentication
SENT_DM_WEBHOOK_SECRETYes (production)Secret for verifying webhook signatures
SENT_BASE_URLNoAPI base URL (default: https://api.sent.dm)
SENT_TIMEOUTNoRequest timeout in seconds (default: 30)
SENT_RETRY_ATTEMPTSNoNumber of retry attempts for failed requests (default: 3)
REDIS_URLNoRedis 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/0

Configuration

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.configure

Base 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?
end

Service 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
end

Send 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
end

Send 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
end

Concerns

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
end

Model 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
end

Controllers

# 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
end

ActiveJob 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
end

Models

# 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
end

Routes

# 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'
end

Testing 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 }
end

Factories

# 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
end

Service 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
end

Job 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
end

Sidekiq 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

On this page