Ruby SDK

Sending messages from Sinatra with the Sent Ruby SDK

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

Prerequisites

This guide assumes a working Sinatra app (classic or modular) and familiarity with routes and helpers. 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"

Memoize one client for the whole process:

# lib/sent_client.rb
require 'sentdm'

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

Send a template message from a route

Add a route that calls messages.send_; the pass-through sandbox flag lets callers exercise the route without delivering anything:

# app.rb
require 'sinatra/base'
require 'json'
require_relative 'lib/sent_client'

class App < Sinatra::Base
  post '/api/messages/send' do
    content_type :json
    data = JSON.parse(request.body.read, symbolize_names: true)

    response = SentClient.client.messages.send_(
      to: [data.fetch(:phone_number)],        # E.164 format, for example +14155551234
      template: {
        name: data.fetch(:template_name),     # reference by name or id, never both
        parameters: data.fetch(:parameters, {})
      },
      channel: data[:channels],               # omit to let Sent pick per recipient
      sandbox: data.fetch(:sandbox, false)    # true = validate and simulate only
    )

    recipient = response.data.recipients[0]
    status 202
    { message_id: recipient.message_id, status: response.data.status }.to_json
  rescue Sentdm::Errors::BadRequestError, Sentdm::Errors::UnprocessableEntityError => e
    halt 422, { error: e.message }.to_json
  rescue JSON::ParserError, KeyError
    halt 400, { error: 'phone_number and template_name are required' }.to_json
  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 helper that verifies the X-Webhook-Signature header against the raw request body before your route trusts any event. 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:

# lib/webhook_helpers.rb
require 'openssl'
require 'base64'

module WebhookHelpers
  TIMESTAMP_TOLERANCE = 300 # seconds; reject replayed events

  # Signing secret is "whsec_{base64Key}"; signed content is "{webhookId}.{timestamp}.{rawBody}";
  # signature header is "v1,{base64(hmac)}".
  def verify_webhook_signature!(payload, webhook_id, timestamp, signature)
    secret = ENV['SENT_DM_WEBHOOK_SECRET']
    # Fail closed: never accept webhooks without a configured secret
    halt 500, { error: 'Webhook not configured' }.to_json if secret.to_s.empty?

    key_bytes = Base64.strict_decode64(secret.delete_prefix('whsec_'))
    digest    = OpenSSL::HMAC.digest('SHA256', key_bytes, "#{webhook_id}.#{timestamp}.#{payload}")
    expected  = "v1,#{Base64.strict_encode64(digest)}"

    halt 401, { error: 'Invalid webhook signature' }.to_json unless Rack::Utils.secure_compare(expected, signature.to_s)
    halt 401, { error: 'Webhook timestamp too old' }.to_json if (Time.now.to_i - timestamp.to_i).abs > TIMESTAMP_TOLERANCE
  end
end

Add the route. 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 elsewhere:

# app.rb (additions)
require_relative 'lib/webhook_helpers'

class App < Sinatra::Base
  helpers WebhookHelpers
  configure { enable :logging }

  post '/webhooks/sent' do
    content_type :json
    payload = request.body.read
    request.body.rewind
    verify_webhook_signature!(payload,
                              env['HTTP_X_WEBHOOK_ID'] || '',
                              env['HTTP_X_WEBHOOK_TIMESTAMP'] || '',
                              env['HTTP_X_WEBHOOK_SIGNATURE'] || '')

    event = JSON.parse(payload)
    data  = event['payload'] || {}

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

    { received: true }.to_json
  rescue JSON::ParserError
    halt 400, { error: 'Invalid JSON' }.to_json
  end
end

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": "Sinatra 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:

bundle exec rackup

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

curl -X POST http://localhost:9292/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 400 or 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 application 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 record keyed by message_id when you send, then update it from the webhook route. The appendix below has a Sequel model sketch.
  • If webhook processing does slow work (database writes, downstream calls), acknowledge with 200 first and hand off to a job queue such as Sidekiq so retries do not pile up; see handling webhook retries.
  • If you run the app behind Puma with multiple workers, the memoized client is created once per worker process, which is the intended pattern.
  • 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 Sinatra stack. Adapt them to your own conventions rather than adopting them wholesale.

Next steps

On this page