Ruby SDK

Ruby SDK

The official Ruby SDK for Sent LogoSent provides an elegant, Ruby-idiomatic interface for sending messages. Built with love for Rails applications, featuring comprehensive types & docstrings in Yard, RBS, and RBI.

Requirements

Ruby 3.2.0 or later.

Installation

To use this gem, install via Bundler by adding the following to your application's Gemfile:

gem "sentdm", "~> 0.13.0"

Then run:

bundle install

Or install directly:

gem install sentdm

Quick Start

Initialize the client

require "sentdm"

sent_dm = Sentdm::Client.new(
  api_key: ENV["SENT_DM_API_KEY"]  # This is the default and can be omitted
)

Send your first message

require "sentdm"

sent_dm = Sentdm::Client.new

result = sent_dm.messages.send_(
  to: ["+1234567890"],
  template: {
    id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    name: "welcome",
    parameters: {
      name: "John Doe",
      order_id: "12345"
    }
  },
  channel: ["sms", "whatsapp"]
)

puts(result.data.messages[0].id)
puts(result.data.messages[0].status)

Authentication

The client reads SENT_DM_API_KEY from the environment by default, or you can pass it explicitly:

require "sentdm"

# Using environment variables
sent_dm = Sentdm::Client.new

# Or explicit configuration
sent_dm = Sentdm::Client.new(
  api_key: "your_api_key"
)

Send Messages

The Ruby SDK uses send_ (with trailing underscore) instead of send because send is a reserved method in Ruby's Object class.

Send a message

result = sent_dm.messages.send_(
  to: ["+1234567890"],
  template: {
    id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    name: "welcome",
    parameters: {
      name: "John Doe",
      order_id: "12345"
    }
  },
  channel: ["sms", "whatsapp"]
)

puts(result.data.messages[0].id)
puts(result.data.messages[0].status)

Test mode

Use test_mode: true to validate requests without sending real messages:

result = sent_dm.messages.send_(
  to: ["+1234567890"],
  template: {
    id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    name: "welcome"
  },
  test_mode: true # Validates but doesn't send
)

# Response will have test data
puts(result.data.messages[0].id)
puts(result.data.messages[0].status)

Handling errors

When the library is unable to connect to the API, or if the API returns a non-success status code (i.e., 4xx or 5xx response), a subclass of Sentdm::Errors::APIError will be thrown:

begin
  result = sent_dm.messages.send_(
    to: ["+1234567890"],
    template: {
      id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
      name: "welcome",
      parameters: {name: "John Doe", order_id: "12345"}
    }
  )
rescue Sentdm::Errors::APIConnectionError => e
  puts("The server could not be reached")
  puts(e.cause) # an underlying Exception, likely raised within `net/http`
rescue Sentdm::Errors::RateLimitError => e
  puts("A 429 status code was received; we should back off a bit.")
rescue Sentdm::Errors::APIStatusError => e
  puts("Another non-200-range status code was received")
  puts(e.status)
end

Error codes are as follows:

CauseError Type
HTTP 400BadRequestError
HTTP 401AuthenticationError
HTTP 403PermissionDeniedError
HTTP 404NotFoundError
HTTP 409ConflictError
HTTP 422UnprocessableEntityError
HTTP 429RateLimitError
HTTP >= 500InternalServerError
Other HTTP errorAPIStatusError
TimeoutAPITimeoutError
Network errorAPIConnectionError

Retries

Certain errors will be automatically retried 2 times by default, with a short exponential backoff.

# Configure the default for all requests:
sent_dm = Sentdm::Client.new(
  max_retries: 0 # default is 2
)

# Or, configure per-request:
sent_dm.messages.send_(
  to: ["+1234567890"],
  template: {
    id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    name: "welcome"
  },
  request_options: {max_retries: 5}
)

Timeouts

By default, requests will time out after 60 seconds.

# Configure the default for all requests:
sent_dm = Sentdm::Client.new(
  timeout: nil # default is 60
)

# Or, configure per-request:
sent_dm.messages.send_(
  to: ["+1234567890"],
  template: {
    id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    name: "welcome"
  },
  request_options: {timeout: 5}
)

BaseModel

All parameter and response objects inherit from Sentdm::Internal::Type::BaseModel, which provides several conveniences:

  1. All fields, including unknown ones, are accessible with obj[:prop] syntax
  2. Structural equivalence for equality
  3. Both instances and classes can be pretty-printed
  4. Helpers such as #to_h, #deep_to_h, #to_json, and #to_yaml
result = sent_dm.templates.list

# Access fields
template = result.data.data.first
template.name
template[:name]  # Same thing

# Convert to hash
template.to_h

# Serialize to JSON
template.to_json

# Pretty print
puts template.inspect

Contacts

Create and manage contacts:

# Create a contact
result = sent_dm.contacts.create(
  phone_number: "+1234567890"
)

puts "Contact ID: #{result.data.id}"
puts "Channels: #{result.data.available_channels}"

# List contacts
result = sent_dm.contacts.list(limit: 100)

result.data.data.each do |contact|
  puts "#{contact.phone_number} - #{contact.available_channels}"
end

# Get a contact
result = sent_dm.contacts.get("contact-uuid")

# Update a contact
result = sent_dm.contacts.update(
  "contact-uuid",
  phone_number: "+1987654321"
)

# Delete a contact
sent_dm.contacts.delete("contact-uuid")

Templates

List and retrieve templates:

# List templates
result = sent_dm.templates.list

result.data.data.each do |template|
  puts "#{template.name} (#{template.status}): #{template.id}"
  puts "  Category: #{template.category}"
  puts "  Channels: #{template.channels.join(', ')}"
end

# Get a specific template
result = sent_dm.templates.get("template-uuid")

puts "Name: #{result.data.name}"
puts "Status: #{result.data.status}"

Webhooks

Recommended pattern: Webhooks are the primary way to track message delivery — don't poll the API. Save the message ID when you send, then update your database as webhook events arrive.

Sent delivers signed POST requests to your endpoint for every status change. Two event types exist:

  • messages — Message status changes (SENT, DELIVERED, READ, FAILED, …)
  • templates — WhatsApp template approval/rejection

The signing secret (from the Sent Dashboard) has a whsec_ prefix. Strip it and base64-decode the remainder to obtain the raw HMAC key. The signed content is {X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody} and the signature format is v1,{base64(hmac)}. See the Rails and Sinatra integration sections below for full examples, and the Webhooks reference for the full payload schema.

Making custom or undocumented requests

Undocumented properties

You can send undocumented parameters to any endpoint using extra_* options:

result = sent_dm.messages.send_(
  to: ["+1234567890"],
  template: {
    id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    name: "welcome"
  },
  request_options: {
    extra_query: {my_query_parameter: value},
    extra_body: {my_body_parameter: value},
    extra_headers: {"my-header" => value}
  }
)

puts(result[:my_undocumented_property])

Undocumented endpoints

To make requests to undocumented endpoints:

response = sent_dm.request(
  method: :post,
  path: '/undocumented/endpoint',
  query: {"dog" => "woof"},
  headers: {"useful-header" => "interesting-value"},
  body: {"hello" => "world"}
)

Concurrency & connection pooling

The Sentdm::Client instances are thread-safe, but are only fork-safe when there are no in-flight HTTP requests.

Each instance of Sentdm::Client has its own HTTP connection pool with a default size of 99. As such, we recommend instantiating the client once per application in most settings.

When all available connections from the pool are checked out, requests wait for a new connection to become available, with queue time counting towards the request timeout.

Rails Integration

Configuration

# config/initializers/sentdm.rb
$SENT_DM = Sentdm::Client.new(
  api_key: ENV['SENT_DM_API_KEY']
)

# Usage anywhere
$SENT_DM.messages.send_(
  to: ['+1234567890'],
  template: {
    id: 'welcome-template',
    name: 'welcome'
  }
)

Controllers

# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  skip_before_action :verify_authenticity_token, only: [:webhook]

  def send_welcome
    result = $SENT_DM.messages.send_(
      to: [params[:phone]],
      template: {
        id: 'welcome-template',
        name: 'welcome',
        parameters: {name: params[:name]}
      }
    )

    if result.success
      render json: {
        success: true,
        message_id: result.data.messages[0].id,
        status: result.data.messages[0].status
      }
    else
      render json: {error: result.error.message}, status: :bad_request
    end
  end

  def webhook
    require 'openssl'
    require 'base64'

    # 1. Read raw body
    payload    = request.body.read
    webhook_id = request.headers['X-Webhook-ID'] || ''
    timestamp  = request.headers['X-Webhook-Timestamp'] || ''
    signature  = request.headers['X-Webhook-Signature'] || ''

    # 2. Verify: signed content = "{webhookId}.{timestamp}.{rawBody}"
    secret     = ENV['SENT_DM_WEBHOOK_SECRET']         # "whsec_abc123..."
    key_bytes  = Base64.strict_decode64(secret.delete_prefix('whsec_'))
    signed     = "#{webhook_id}.#{timestamp}.#{payload}"
    digest     = OpenSSL::HMAC.digest('SHA256', key_bytes, signed)
    expected   = "v1,#{Base64.strict_encode64(digest)}"

    unless ActiveSupport::SecurityUtils.secure_compare(expected, signature)
      return render json: {error: 'Invalid signature'}, status: :unauthorized
    end

    # 3. Optional: reject replayed events older than 5 minutes
    if (Time.now.to_i - timestamp.to_i).abs > 300
      return render json: {error: 'Timestamp too old'}, status: :unauthorized
    end

    event = JSON.parse(payload, symbolize_names: true)

    # 4. Update message status in your own database
    if event[:field] == 'messages'
      Message.where(sent_id: event[:payload][:message_id])
             .update_all(status: event[:payload][:message_status])
    end

    # 5. Always return 200 quickly
    render json: {received: true}
  end
end

ActiveJob Integration

# app/jobs/send_welcome_message_job.rb
class SendWelcomeMessageJob < ApplicationJob
  queue_as :default

  retry_on Sentdm::Errors::RateLimitError, wait: :polynomially_longer, attempts: 5
  discard_on Sentdm::Errors::ValidationError

  def perform(user)
    result = $SENT_DM.messages.send_(
      to: [user.phone_number],
      template: {
        id: 'welcome-template',
        name: 'welcome',
        parameters: {name: user.first_name}
      }
    )

    raise result.error.message unless result.success
  end
end

Rake Tasks

# lib/tasks/sentdm.rake
namespace :sentdm do
  desc "Send test message"
  task :test, [:phone, :template] => :environment do |t, args|
    args.with_defaults(template: 'welcome-template')

    puts "Sending test message to #{args.phone}..."

    result = $SENT_DM.messages.send_(
      to: [args.phone],
      template: {
        id: args.template,
        name: 'welcome'
      }
    )

    if result.success
      puts "Sent: #{result.data.messages[0].id}"
    else
      puts "Failed: #{result.error.message}"
      exit 1
    end
  end
end

Sinatra Integration

# app.rb
require 'sinatra'
require 'sentdm'
require 'json'

configure do
  set :sent_client, Sentdm::Client.new
end

post '/send-message' do
  content_type :json

  data = JSON.parse(request.body.read)

  result = settings.sent_client.messages.send_(
    to: [data['phone_number']],
    template: {
      id: data['template_id'],
      name: data['template_name'] || 'welcome',
      parameters: data['variables'] || {}
    }
  )

  if result.success
    {message_id: result.data.messages[0].id, status: result.data.messages[0].status}.to_json
  else
    status 400
    {error: result.error.message}.to_json
  end
end

post '/webhooks/sent' do
  require 'openssl'
  require 'base64'
  content_type :json

  # 1. Read raw body
  payload    = request.body.read
  webhook_id = request.env['HTTP_X_WEBHOOK_ID'] || ''
  timestamp  = request.env['HTTP_X_WEBHOOK_TIMESTAMP'] || ''
  signature  = request.env['HTTP_X_WEBHOOK_SIGNATURE'] || ''

  # 2. Verify: signed content = "{webhookId}.{timestamp}.{rawBody}"
  secret    = ENV['SENT_DM_WEBHOOK_SECRET']            # "whsec_abc123..."
  key_bytes = Base64.strict_decode64(secret.delete_prefix('whsec_'))
  signed    = "#{webhook_id}.#{timestamp}.#{payload}"
  digest    = OpenSSL::HMAC.digest('SHA256', key_bytes, signed)
  expected  = "v1,#{Base64.strict_encode64(digest)}"

  unless Rack::Utils.secure_compare(expected, signature)
    status 401
    return {error: 'Invalid signature'}.to_json
  end

  # 3. Optional: reject replayed events older than 5 minutes
  if (Time.now.to_i - timestamp.to_i).abs > 300
    status 401
    return {error: 'Timestamp too old'}.to_json
  end

  event = JSON.parse(payload, symbolize_names: true)

  # 4. Update message status in your own database
  if event[:field] == 'messages'
    # DB[:messages].where(sent_id: event[:payload][:message_id])
    #              .update(status: event[:payload][:message_status])
    puts "Message #{event[:payload][:message_id]}#{event[:payload][:message_status]}"
  end

  # 5. Always return 200 quickly
  {received: true}.to_json
end

Sorbet

This library provides comprehensive RBI definitions and has no dependency on sorbet-runtime.

You can provide typesafe request parameters:

sent_dm.messages.send_(
  to: ["+1234567890"],
  template: {
    id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    name: "welcome",
    parameters: {name: "John Doe"}
  }
)

Source & Issues

Getting Help

On this page