Ruby SDK

Sending messages from Rails with the Sent Ruby SDK

This guide shows you how to wire Sent messaging into an existing Rails app: install the Ruby SDK, configure a shared client, send a template message from a controller, receive delivery webhooks, and verify the whole loop in sandbox mode.

Prerequisites

This guide assumes a working Rails 7.1+ app and familiarity with controllers, concerns, and initializers. You also need:

Install the SDK

Add the gem to your Gemfile and install:

gem "sentdm", "~> 0.25.0"
bundle install

Configure the client

Set your credentials as environment variables so they stay out of code; the webhook secret arrives in step 4:

export SENT_DM_API_KEY="your-api-key"
export SENT_DM_WEBHOOK_SECRET="whsec_your_signing_secret"

Create an initializer that validates configuration at boot and memoizes one client for the whole process:

# config/initializers/sentdm.rb
module SentDmConfig
  class ConfigurationError < StandardError; end

  class << self
    def configure
      raise ConfigurationError, 'SENT_DM_API_KEY required' if ENV['SENT_DM_API_KEY'].blank?
      client # eagerly initialize and memoize
    end

    def client
      @client ||= Sentdm::Client.new(api_key: ENV.fetch('SENT_DM_API_KEY'))
    end
  end
end

SentDmConfig.configure

Send a template message from a controller

Add a controller that calls messages.send_; the pass-through sandbox flag lets callers exercise the endpoint without delivering anything. Protect the route with your existing API authentication:

# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  skip_before_action :verify_authenticity_token   # JSON API endpoint; use your own auth

  def create
    response = SentDmConfig.client.messages.send_(
      to: [params.require(:phone_number)],          # E.164 format, for example +14155551234
      template: {
        name: params.require(:template_name),       # reference by name or id, never both
        parameters: params.fetch(:parameters, {}).permit!.to_h
      },
      channel: params[:channels],                   # omit to let Sent pick per recipient
      sandbox: ActiveModel::Type::Boolean.new.cast(params[:sandbox])  # true = simulate only
    )
    recipient = response.data.recipients[0]
    render json: { message_id: recipient.message_id, status: response.data.status }, status: :accepted
  rescue Sentdm::Errors::UnprocessableEntityError, Sentdm::Errors::BadRequestError => e
    render json: { error: e.message }, status: :unprocessable_entity
  end
end

Sent accepts sends asynchronously: the API responds with status QUEUED and one message_id per recipient-and-channel pair. Store the message_id: delivery outcomes arrive on your webhook endpoint instead of in this response.

Receive delivery webhooks

Add a concern that verifies the X-Webhook-Signature header against the raw request body before any handler runs. The scheme is HMAC-SHA256 over {X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}, keyed with the base64-decoded secret after stripping its whsec_ prefix. Refer to webhook signature verification for the full scheme:

# app/controllers/concerns/webhook_verifiable.rb
module WebhookVerifiable
  extend ActiveSupport::Concern
  class SignatureVerificationError < StandardError; end

  TIMESTAMP_TOLERANCE = 300 # seconds; reject replayed events

  included do
    skip_before_action :verify_authenticity_token, only: [:create]
    before_action :verify_webhook_signature, only: [:create]
    rescue_from SignatureVerificationError, with: :handle_invalid_signature
  end

  private

  def verify_webhook_signature
    webhook_id = request.headers['X-Webhook-ID']
    timestamp  = request.headers['X-Webhook-Timestamp']
    signature  = request.headers['X-Webhook-Signature']
    payload = request.body.read; request.body.rewind

    raise SignatureVerificationError, 'Missing signature headers' if [webhook_id, timestamp, signature].any?(&:blank?)
    raise SignatureVerificationError, 'Timestamp outside tolerance' if (Time.now.to_i - timestamp.to_i).abs > TIMESTAMP_TOLERANCE
    raise SignatureVerificationError, 'Invalid signature' unless secure_compare(expected_signature(webhook_id, timestamp, payload), signature)
  end

  def expected_signature(webhook_id, timestamp, payload)
    secret    = ENV.fetch('SENT_DM_WEBHOOK_SECRET')  # "whsec_..."
    key_bytes = Base64.strict_decode64(secret.delete_prefix('whsec_'))
    digest    = OpenSSL::HMAC.digest('SHA256', key_bytes, "#{webhook_id}.#{timestamp}.#{payload}")
    "v1,#{Base64.strict_encode64(digest)}"
  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

Add the controller. Every event arrives in the same envelope (field, event, timestamp, payload), so one handler routes all of them; return 200 quickly and do slow work in a job:

# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
  include WebhookVerifiable

  def create
    payload = request.body.read; request.body.rewind
    event = JSON.parse(payload)
    data  = event['payload'] || {}

    if event['field'] == 'message'
      case event['event']    # sub-type; omitted for template events
      when 'message.delivered'
        Rails.logger.info "Message #{data['message_id']} delivered"
      when 'message.failed'
        Rails.logger.error "Message #{data['message_id']} failed (status #{data['message_status']})"
      when 'message.received'
        Rails.logger.info "Inbound #{data['channel']} from #{data['inbound_number']}: #{data['text']}"
      else
        Rails.logger.info "Message #{data['message_id']} status: #{data['message_status']}"
      end
    end

    render json: { received: true }
  rescue JSON::ParserError
    render json: { error: 'Invalid JSON' }, status: :bad_request
  end
end

Route both controllers:

# config/routes.rb
post "/api/messages/send", to: "messages#create"
post "/webhooks/sent", to: "webhooks#create"

Then tell Sent where to deliver events. If you prefer a UI, use the webhooks getting started guide; otherwise register over the API:

curl -X POST https://api.sent.dm/v3/webhooks \
  -H "x-api-key: $SENT_DM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "display_name": "Rails integration",
    "endpoint_url": "https://your-domain.example/webhooks/sent",
    "event_types": ["message"]
  }'

Copy two values from the response: the webhook id (used to test delivery in the next step) and signing_secret (put it in SENT_DM_WEBHOOK_SECRET). The message event type covers every message event; the webhook event types reference lists all payload fields.

Verify the integration

Start the app with your credentials loaded:

bin/rails server

Send a sandbox message through your new endpoint. Full validation runs, but nothing is delivered and no credits are consumed:

curl -X POST http://localhost:3000/api/messages/send \
  -H "Content-Type: application/json" \
  -d '{"phone_number": "+14155551234", "template_name": "welcome",
       "parameters": {"name": "Ada"}, "sandbox": true}'

The response should contain a message_id and "status": "QUEUED". A 422 here means the request shape is wrong: sandbox requests return real validation errors.

Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook id you copied:

curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \
  -H "x-api-key: $SENT_DM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "message.delivered"}'

Your Rails log should show a Message ... delivered line, and the endpoint should have answered 200 {"received": true}. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix.

Adapt this to your app

  • If you track delivery state, persist a Message record keyed by the returned message_id and update it from webhook events. The appendix below has the model and handler.
  • If you send in bulk or on user lifecycle events, move the send_ call into an ActiveJob. The appendix has the job.
  • If a webhook references a message_id you do not recognize, acknowledge it with 200 anyway; failing causes Sent to retry the delivery.
  • To send free-form text instead of a template, pass text instead of template. Each send carries exactly one of the two.

Appendix: production scaffolding

The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production Rails stack. Adapt them to your own conventions rather than adopting them wholesale.

Next steps

On this page