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:
- A Sent API key from the API Keys page in your Sent Dashboard
- A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the webhook local development guide
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.configureSend 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
endSent 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
endAdd 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
endRoute 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 serverSend 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
Messagerecord keyed by the returnedmessage_idand 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_idyou 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
textinstead oftemplate. 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
- Review the webhook event types reference for every payload field
- Work through the webhook production checklist before going live
- Explore the Ruby SDK reference for retries, timeouts, and error types
- Read the SDK best practices guide for production deployments
Ruby SDK
Official Ruby SDK for Sent. Elegant DSL for Rails applications with full type support.
Sending messages from Sinatra with the Sent Ruby SDK
Wire the Sent Ruby SDK into a Sinatra app: install, configure the client, send a template message from a route, verify webhooks, and test with sandbox mode.