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.rbGemfile
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'
endConfiguration
# 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
endDatabase 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
endValidation 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
endServices 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
endMiddleware
# 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
endHelpers
# 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
endApplication 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
endRack & 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_restartTesting
# 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
endEnvironment 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=infoDocker
# 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 --buildNext Steps
- Review Ruby SDK documentation for advanced features
- Learn about webhook best practices
- Explore testing patterns for comprehensive coverage