Ruby SDK

Sinatra Integration

Complete Sinatra 4.0 integration with modular architecture, middleware stack, and production-ready patterns.

This example uses Sinatra 4.0 with modular style, dry-rb validation, and Rack middleware for production deployments.

Project Structure

sent-sinatra-app/
├── Gemfile
├── config.ru
├── config/puma.rb
├── lib/
│   ├── sent_app.rb
│   ├── sent_app/
│   │   ├── api.rb
│   │   ├── webhooks.rb
│   │   ├── helpers.rb
│   │   ├── middleware/
│   │   │   ├── request_logger.rb
│   │   │   └── api_authentication.rb
│   │   ├── services/message_service.rb
│   │   ├── validators/send_message_contract.rb
│   │   └── models/message.rb
│   └── config.rb
├── db/sequel.rb
└── spec/
    ├── spec_helper.rb
    ├── api_spec.rb
    └── webhooks_spec.rb

Gemfile

source 'https://rubygems.org'
ruby '~> 3.2'

gem 'sinatra', '~> 4.0'
gem 'sinatra-contrib', '~> 4.0'
gem 'puma', '~> 6.4'
gem 'sequel', '~> 5.75'
gem 'pg', '~> 1.5'
gem 'dry-validation', '~> 1.10'
gem 'dry-monads', '~> 1.6'
gem 'dotenv', '~> 3.0'
gem 'sent_dm', '~> 1.0'

group :development do
  gem 'rerun', '~> 0.14'
  gem 'pry', '~> 0.14'
end

group :test do
  gem 'rspec', '~> 3.12'
  gem 'rack-test', '~> 2.1'
  gem 'database_cleaner-sequel', '~> 2.0'
  gem 'webmock', '~> 3.19'
end

Configuration

# lib/config.rb
require 'dotenv'
require 'singleton'
Dotenv.load

module SentApp
  class Config
    include Singleton
    DEFAULTS = { port: 3000, env: 'development', log_level: 'info', db_pool: 5 }.freeze

    def self.[](key); instance[key]; end
    def initialize; @config = DEFAULTS.merge(load_from_env); end
    def [](key); @config[key.to_sym]; end
    def fetch(key, default = nil); @config.fetch(key.to_sym, default); end
    def api_key; @config[:sent_api_key] || raise('SENT_DM_API_KEY required'); end
    def webhook_secret; @config[:sent_webhook_secret]; end
    def environment; @config[:env]; end
    def development?; environment == 'development'; end
    def test?; environment == 'test'; end
    def production?; environment == 'production'; end

    private

    def load_from_env
      { port: ENV['PORT']&.to_i, env: ENV['RACK_ENV'] || 'development',
        sent_api_key: ENV['SENT_DM_API_KEY'], sent_webhook_secret: ENV['SENT_DM_WEBHOOK_SECRET'],
        sent_base_url: ENV['SENT_BASE_URL'], db_url: ENV['DATABASE_URL'],
        log_level: ENV['LOG_LEVEL'] }.compact
    end
  end
end

Database Setup

# db/sequel.rb
require 'sequel'
require_relative '../lib/config'

DB = Sequel.connect(
  SentApp::Config.fetch(:db_url, 'sqlite://db/development.db'),
  pool_timeout: SentApp::Config.fetch(:db_timeout, 5000),
  max_connections: SentApp::Config.fetch(:db_pool, 5),
  logger: SentApp::Config.development? ? Logger.new($stdout) : nil
)
DB.extension :connection_validator
DB.pool.connection_validation_timeout = -1
# lib/sent_app/models/message.rb
require_relative '../../../db/sequel'

module SentApp
  module Models
    class Message < Sequel::Model(:messages)
      plugin :timestamps, update_on_create: true
      plugin :validation_helpers

      STATUSES = %w[pending queued sent delivered failed cancelled].freeze

      def validate
        super
        validates_presence [:phone_number, :template_id]
        validates_includes STATUSES, :status
        validates_format /\A\+?[1-9]\d{1,14}\z/, :phone_number
      end

      def mark_as_sent!(sent_id); update(sent_id: sent_id, status: 'sent', sent_at: Time.now); end
      def mark_as_delivered!; update(status: 'delivered', delivered_at: Time.now); end
      def mark_as_failed!(error); update(status: 'failed', failed_at: Time.now, error_message: error.to_s); end
    end
  end
end

Validation Contracts

# lib/sent_app/validators/send_message_contract.rb
require 'dry-validation'

module SentApp
  module Validators
    class SendMessageContract < Dry::Validation::Contract
      params do
        required(:phone_number).filled(:string)
        required(:template_id).filled(:string)
        optional(:template_name).maybe(:string)
        optional(:variables).maybe(:hash)
        optional(:channels).array(:string)
      end

      rule(:phone_number) do
        key.failure('must be valid E.164 format') unless value.match?(/\A\+?[1-9]\d{1,14}\z/)
      end

      rule(:channels) do
        if key? && value
          invalid = value - %w[sms whatsapp email push]
          key.failure("invalid channels: #{invalid.join(', ')}") unless invalid.empty?
        end
      end
    end

    class WebhookContract < Dry::Validation::Contract
      params do
        required(:type).filled(:string)
        required(:data).filled(:hash)
      end

      rule(:type) do
        allowed = %w[message.delivered message.failed message.sent message.queued message.read]
        key.failure("unknown event type: #{value}") unless allowed.include?(value)
      end
    end
  end
end

Services Layer

# lib/sent_app/services/message_service.rb
require 'dry/monads'
require_relative '../validators/send_message_contract'
require_relative '../models/message'

module SentApp
  module Services
    class MessageService
      include Dry::Monads[:result, :do]

      def initialize(client: nil, logger: Logger.new($stdout))
        @client = client || Sentdm::Client.new(Config.api_key)
        @logger = logger
      end

      def send_message(params)
        validation = Validators::SendMessageContract.new.call(params)
        return Failure(validation.errors.to_h) if validation.failure?
        validated = validation.to_h

        message = Models::Message.create(
          phone_number: validated[:phone_number], template_id: validated[:template_id],
          template_name: validated[:template_name], variables: validated[:variables]&.to_json,
          channel: validated[:channels]&.first, status: 'pending'
        )

        result = @client.messages.send(
          phone_number: validated[:phone_number], template_id: validated[:template_id],
          template_name: validated[:template_name], variables: validated[:variables] || {},
          channels: validated[:channels]
        )

        if result.success
          message.mark_as_sent!(result.data.id)
          Success(message)
        else
          message.mark_as_failed!(result.error.message)
          Failure(error: result.error.message, code: result.error.code)
        end
      rescue StandardError => e
        @logger.error "MessageService error: #{e.message}"
        message&.mark_as_failed!(e.message)
        Failure(error: e.message, code: :internal_error)
      end

      def send_welcome(phone_number, name: nil)
        send_message(phone_number: phone_number, template_id: 'welcome-template',
                     template_name: 'welcome', variables: { name: name || 'Customer' }.compact,
                     channels: ['whatsapp'])
      end

      def process_webhook(event_data)
        event = OpenStruct.new(event_data)
        case event.type
        when 'message.delivered' then handle_delivered(event.data)
        when 'message.failed' then handle_failed(event.data)
        when 'message.sent' then @logger.info "Message #{event.data.id} confirmed sent"
        else @logger.info "Unhandled webhook: #{event.type}"
        end
        Success(event.type)
      rescue StandardError => e
        @logger.error "Webhook error: #{e.message}"
        Failure(error: e.message)
      end

      private

      def handle_delivered(data)
        return unless data.id
        message = Models::Message.first(sent_id: data.id)
        message ? message.mark_as_delivered! : @logger.warn("Message #{data.id} not found")
      end

      def handle_failed(data)
        return unless data.id
        message = Models::Message.first(sent_id: data.id)
        message&.mark_as_failed!(data.error&.message || 'Unknown error')
      end
    end
  end
end

Middleware

# lib/sent_app/middleware/request_logger.rb
module SentApp
  module Middleware
    class RequestLogger
      def initialize(app, logger: nil)
        @app = app
        @logger = logger || Logger.new($stdout)
      end

      def call(env)
        start = Time.now
        request_id = "#{Time.now.to_i}-#{SecureRandom.hex(4)}"
        env['REQUEST_ID'] = request_id

        @logger.info "[#{request_id}] #{env['REQUEST_METHOD']} #{env['PATH_INFO']} started"
        status, headers, body = @app.call(env)
        duration = ((Time.now - start) * 1000).round(2)
        @logger.info "[#{request_id}] #{env['REQUEST_METHOD']} #{env['PATH_INFO']} completed status=#{status} duration=#{duration}ms"

        [status, headers, body]
      rescue StandardError => e
        @logger.error "[#{request_id}] Error: #{e.message}"
        raise
      end
    end
  end
end
# lib/sent_app/middleware/api_authentication.rb
module SentApp
  module Middleware
    class ApiAuthentication
      def initialize(app, excluded_paths: [])
        @app = app
        @excluded_paths = excluded_paths
      end

      def call(env)
        request = Rack::Request.new(env)
        return @app.call(env) if @excluded_paths.any? { |p| request.path.start_with?(p) }
        return @app.call(env) if request.path.include?('/webhooks')

        auth_header = env['HTTP_AUTHORIZATION']
        unless auth_header && auth_header.gsub(/Bearer\s+/i, '').length >= 20
          return [401, { 'Content-Type' => 'application/json' },
                  [{ error: 'Unauthorized', message: 'Valid API token required' }.to_json]]
        end

        env['AUTHENTICATED'] = true
        @app.call(env)
      end
    end
  end
end

Helpers

# lib/sent_app/helpers.rb
module SentApp
  module Helpers
    def json_response(data, status: 200)
      content_type :json
      status status
      data.to_json
    end

    def error_response(message, status: 400, code: nil)
      error = { error: message }
      error[:code] = code if code
      json_response(error, status: status)
    end

    def parse_json_body
      body = request.body.read
      return {} if body.empty?
      JSON.parse(body, symbolize_names: true)
    rescue JSON::ParserError
      halt 400, error_response('Invalid JSON')
    end

    def logger; @logger ||= Logger.new($stdout); end
    def request_id; env['REQUEST_ID'] || 'unknown'; end

    def paginate(dataset, page: 1, per_page: 20)
      page = [page.to_i, 1].max
      per_page = [[per_page.to_i, 100].min, 1].max
      paginated = dataset.paginate(page, per_page)
      { data: paginated.all, pagination: { page: page, per_page: per_page,
          total: paginated.pagination_record_count, total_pages: (paginated.pagination_record_count.to_f / per_page).ceil } }
    end

    def verify_webhook_signature!(payload, signature)
      return true if Config.test?
      secret = Config.webhook_secret
      return true unless secret

      expected = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret, payload)
      halt 401, error_response('Invalid webhook signature') unless Rack::Utils.secure_compare(signature.to_s, "sha256=#{expected}")
      true
    end
  end
end

Application Classes

# lib/sent_app/api.rb
require 'sinatra/base'
require_relative 'helpers'
require_relative 'services/message_service'

module SentApp
  class Api < Sinatra::Base
    helpers Helpers

    error 400..499 do
      content_type :json
      { error: response.body.join, status: response.status }.to_json
    end

    error 500..599 do
      content_type :json
      logger.error "Server error: #{env['sinatra.error']&.message}"
      { error: 'Internal server error', status: 500 }.to_json
    end

    not_found do
      content_type :json
      { error: 'Not found', path: request.path }.to_json
    end

    before do
      content_type :json
      logger.info "[#{request_id}] Processing #{request.request_method} #{request.path}"
    end

    after do
      logger.info "[#{request_id}] Completed #{status}"
    end

    get '/health' do
      json_response(status: 'ok', timestamp: Time.now.iso8601, version: '1.0.0')
    end

    post '/messages/send' do
      data = parse_json_body
      service = Services::MessageService.new(logger: logger)
      result = service.send_message(data)

      result.either(
        ->(msg) { json_response({ success: true, message_id: msg.id, sent_id: msg.sent_id, status: msg.status }, status: 201) },
        ->(err) { error_response(err[:error] || err, status: 400, code: err[:code]) }
      )
    end

    post '/messages/welcome' do
      data = parse_json_body
      halt 400, error_response('phone_number required') unless data[:phone_number]

      service = Services::MessageService.new(logger: logger)
      result = service.send_welcome(data[:phone_number], name: data[:name])

      result.either(
        ->(msg) { json_response(success: true, message_id: msg.id, status: msg.status) },
        ->(err) { error_response(err[:error] || err, status: 400) }
      )
    end

    get '/messages/:id' do
      message = Models::Message[params[:id].to_i]
      halt 404, error_response('Message not found') unless message

      json_response(id: message.id, sent_id: message.sent_id, phone_number: message.phone_number,
                    template_id: message.template_id, status: message.status,
                    sent_at: message.sent_at&.iso8601, delivered_at: message.delivered_at&.iso8601,
                    created_at: message.created_at.iso8601)
    end

    get '/messages' do
      dataset = Models::Message.order(Sequel.desc(:created_at))
      dataset = dataset.where(status: params[:status]) if params[:status]
      dataset = dataset.where(phone_number: params[:phone_number]) if params[:phone_number]
      json_response(paginate(dataset, page: params[:page] || 1, per_page: params[:per_page] || 20))
    end
  end
end
# lib/sent_app/webhooks.rb
require 'sinatra/base'
require_relative 'helpers'
require_relative 'validators/send_message_contract'

module SentApp
  class Webhooks < Sinatra::Base
    helpers Helpers
    configure { set :show_exceptions, false }

    post '/webhooks/sent' do
      payload = request.body.read
      signature = env['HTTP_X_WEBHOOK_SIGNATURE']
      verify_webhook_signature!(payload, signature)

      data = parse_json_body
      validation = Validators::WebhookContract.new.call(data)
      halt 400, error_response('Invalid webhook payload') unless validation.success?

      service = Services::MessageService.new(logger: logger)
      result = service.process_webhook(data)

      result.either(
        ->(event_type) { json_response(received: true, event: event_type) },
        ->(err) { json_response(received: true, processed: false, error: err[:error]) }
      )
    end

    get '/webhooks/health' do
      json_response(status: 'ok', webhooks_enabled: true)
    end
  end
end
# lib/sent_app.rb
require 'sinatra/base'
require_relative 'config'
require_relative 'sent_app/helpers'
require_relative 'sent_app/models/message'
require_relative 'sent_app/api'
require_relative 'sent_app/webhooks'
require_relative 'sent_app/middleware/request_logger'
require_relative 'sent_app/middleware/api_authentication'

module SentApp
  class Application < Sinatra::Base
    configure do
      set :environment, Config.environment.to_sym
      set :root, File.expand_path('..', __dir__)
      set :sessions, true
      set :session_secret, Config.fetch(:session_secret, SecureRandom.hex(64))
      set :protection, except: :path_traversal
      set :show_exceptions, false
      set :raise_errors, false
    end

    configure :development do
      require 'sinatra/reloader'
      register Sinatra::Reloader
      also_reload 'lib/**/*.rb'
    end

    use Middleware::RequestLogger
    use Middleware::ApiAuthentication, excluded_paths: ['/health', '/webhooks']
    use Api
    use Webhooks

    get '/' do
      json_response(name: 'Sent DM API', version: '1.0.0', documentation: '/docs', health: '/health')
    end
  end
end

Rack & Puma Configuration

# config.ru
require_relative 'lib/sent_app'
use Rack::Deflater
use Rack::ContentType, 'application/json'
run SentApp::Application
# config/puma.rb
require_relative '../lib/config'
config = SentApp::Config

port config.fetch(:port, 3000)
environment config.environment
workers config.fetch(:puma_workers, 2)
threads_count = config.fetch(:puma_threads, 5)
threads threads_count, threads_count
preload_app!
worker_timeout config.fetch(:worker_timeout, 60)
pidfile 'tmp/pids/puma.pid'

on_worker_boot { DB.disconnect if defined?(DB) }
plugin :tmp_restart

Testing

# spec/spec_helper.rb
ENV['RACK_ENV'] = 'test'
require 'rack/test'
require 'rspec'
require 'database_cleaner-sequel'
require 'webmock/rspec'
require_relative '../lib/sent_app'
require_relative '../db/sequel'

DatabaseCleaner.strategy = :transaction
DatabaseCleaner.db = DB

RSpec.configure do |config|
  config.include Rack::Test::Methods
  config.before(:each) { DatabaseCleaner.start }
  config.after(:each) { DatabaseCleaner.clean }
end

def app; SentApp::Application; end
# spec/api_spec.rb
require_relative 'spec_helper'

RSpec.describe 'Messages API' do
  let(:mock_client) { instance_double(Sentdm::Client) }
  let(:mock_messages) { double('messages') }

  before do
    allow(Sentdm::Client).to receive(:new).and_return(mock_client)
    allow(mock_client).to receive(:messages).and_return(mock_messages)
  end

  describe 'GET /health' do
    it 'returns health status' do
      get '/health'
      expect(last_response).to be_ok
      expect(JSON.parse(last_response.body)['status']).to eq('ok')
    end
  end

  describe 'POST /messages/send' do
    let(:valid_params) { { phone_number: '+1234567890', template_id: 'welcome', variables: { name: 'John' } } }

    context 'with valid parameters' do
      before do
        allow(mock_messages).to receive(:send).and_return(
          double('result', success: true, data: double('data', id: 'msg_123'))
        )
      end

      it 'creates and sends a message' do
        post '/messages/send', valid_params.to_json, { 'CONTENT_TYPE' => 'application/json' }
        expect(last_response.status).to eq(201)
        body = JSON.parse(last_response.body)
        expect(body['success']).to be true
        expect(body['sent_id']).to eq('msg_123')
      end
    end

    context 'with invalid parameters' do
      it 'returns validation errors for missing phone_number' do
        post '/messages/send', { template_id: 'welcome' }.to_json, { 'CONTENT_TYPE' => 'application/json' }
        expect(last_response.status).to eq(400)
        expect(JSON.parse(last_response.body)['error']).to include('phone_number')
      end

      it 'returns validation errors for invalid phone format' do
        post '/messages/send', { phone_number: 'invalid', template_id: 'welcome' }.to_json,
             { 'CONTENT_TYPE' => 'application/json' }
        expect(last_response.status).to eq(400)
        expect(JSON.parse(last_response.body)['error']).to include('E.164')
      end
    end
  end

  describe 'GET /messages/:id' do
    let!(:message) do
      SentApp::Models::Message.create(phone_number: '+1234567890', template_id: 'welcome',
                                      status: 'sent', sent_id: 'msg_123')
    end

    it 'returns message details' do
      get "/messages/#{message.id}"
      expect(last_response).to be_ok
      body = JSON.parse(last_response.body)
      expect(body['id']).to eq(message.id)
      expect(body['status']).to eq('sent')
    end

    it 'returns 404 for non-existent message' do
      get '/messages/99999'
      expect(last_response.status).to eq(404)
    end
  end

  describe 'GET /messages' do
    before do
      5.times { |i| SentApp::Models::Message.create(phone_number: "+123456789#{i}", template_id: 'welcome', status: 'sent') }
    end

    it 'returns paginated messages' do
      get '/messages?page=1&per_page=3'
      expect(last_response).to be_ok
      body = JSON.parse(last_response.body)
      expect(body['data'].length).to eq(3)
      expect(body['pagination']['total']).to eq(5)
    end
  end
end
# spec/webhooks_spec.rb
require_relative 'spec_helper'

RSpec.describe 'Webhooks' do
  describe 'POST /webhooks/sent' do
    let(:webhook_secret) { 'test_secret' }
    let(:payload) { { type: 'message.delivered', data: { id: 'msg_123', status: 'delivered' } }.to_json }

    before do
      allow(SentApp::Config).to receive(:webhook_secret).and_return(webhook_secret)
      SentApp::Models::Message.create(phone_number: '+1234567890', template_id: 'welcome',
                                      sent_id: 'msg_123', status: 'sent')
    end

    def generate_signature(payload, secret)
      'sha256=' + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret, payload)
    end

    it 'processes webhook with valid signature' do
      signature = generate_signature(payload, webhook_secret)
      post '/webhooks/sent', payload, { 'CONTENT_TYPE' => 'application/json', 'HTTP_X_WEBHOOK_SIGNATURE' => signature }
      expect(last_response).to be_ok
      expect(JSON.parse(last_response.body)['received']).to be true
    end

    it 'returns 401 with invalid signature' do
      post '/webhooks/sent', payload, { 'CONTENT_TYPE' => 'application/json', 'HTTP_X_WEBHOOK_SIGNATURE' => 'invalid' }
      expect(last_response.status).to eq(401)
    end
  end
end

Environment Variables

# .env.example
SENT_DM_API_KEY=your_api_key_here
SENT_DM_WEBHOOK_SECRET=your_webhook_secret
SENT_BASE_URL=https://api.sent.dm
DATABASE_URL=postgres://user:password@localhost/sent_app_development
PORT=3000
RACK_ENV=development
LOG_LEVEL=info

Docker

# Dockerfile
FROM ruby:3.2-slim

RUN apt-get update && apt-get install -y build-essential libpq-dev && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local deployment 'true' && \
    bundle config set --local without 'development test' && \
    bundle install

COPY . .
RUN mkdir -p tmp/pids log

EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:3000/health || exit 1
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
# docker-compose.yml
version: '3.8'
services:
  app:
    build: .
    ports: ["3000:3000"]
    environment:
      - RACK_ENV=production
      - DATABASE_URL=postgres://postgres:password@db:5432/sent_app
      - SENT_DM_API_KEY=${SENT_DM_API_KEY}
      - SENT_DM_WEBHOOK_SECRET=${SENT_DM_WEBHOOK_SECRET}
    depends_on: [db]
  db:
    image: postgres:15-alpine
    environment: [POSTGRES_PASSWORD=password, POSTGRES_DB=sent_app]
    volumes: [postgres_data:/var/lib/postgresql/data]
volumes:
  postgres_data:

Running

# Development
bundle install
cp .env.example .env
bundle exec rake db:migrate
bundle exec rerun -- puma -C config/puma.rb

# Production
RACK_ENV=production bundle exec rake db:migrate
bundle exec puma -C config/puma.rb

# Testing
bundle exec rspec

# Docker
docker-compose up --build

Next Steps

On this page