Integration

Send email from Ruby on Rails with SNDR.

Send transactional email from Rails using the SNDR HTTP API. No native Ruby SDK — the patterns below use Net::HTTP (no extra dep) for sends and OpenSSL::HMAC for constant-time webhook verification.

There's no official Ruby SDK yet — and you don't need one. SNDR's API is a single POST /v1/send with a typed JSON body and a Bearer token, which Net::HTTP from Ruby's standard library handles cleanly. The patterns below show how to wrap the API in a service object, dispatch sends through ActiveJob (so transactional emails survive a request failure), and verify webhooks with timing-safe HMAC comparison.

Setup

Install + configure

Installbash
# Net::HTTP is in Ruby's standard library.
# No gem install required for the send path.
# Optional: add a small retry gem if you want richer behaviour:
#   gem 'retries'
Environmentenv
# Rails credentials (recommended over .env)
#   bin/rails credentials:edit
#
# sndr:
#   api_key: sndr_live_x9k2...
#   webhook_secret: ...
#   from: hello@yourdomain.com
#   base: https://api.sndr.sh

Recipes

The patterns you actually need.

  1. Service object

    A service object isolates HTTP details. Singleton-style Net::HTTP for connection reuse; raise a typed error on non-2xx so callers can rescue specifically.

    # app/services/sndr/client.rb
    require 'net/http'
    require 'json'
    require 'securerandom'
    require 'uri'
    
    module Sndr
      class Error < StandardError
        attr_reader :code, :status, :request_id
        def initialize(code:, message:, status:, request_id: nil)
          super(message)
          @code, @status, @request_id = code, status, request_id
        end
      end
    
      class Client
        BASE = Rails.application.credentials.dig(:sndr, :base) || 'https://api.sndr.sh'
        API_KEY = Rails.application.credentials.dig(:sndr, :api_key)
    
        def self.send_email(payload, idempotency_key: nil, max_attempts: 3)
          key = idempotency_key || SecureRandom.uuid
          uri = URI("#{BASE}/v1/send")
    
          attempt = 0
          last_err = nil
          loop do
            attempt += 1
            begin
              http = Net::HTTP.new(uri.host, uri.port)
              http.use_ssl = uri.scheme == 'https'
              http.read_timeout = 30
              http.open_timeout = 5
    
              req = Net::HTTP::Post.new(uri.path)
              req['Authorization'] = "Bearer #{API_KEY}"
              req['Content-Type'] = 'application/json'
              req['Idempotency-Key'] = key
              req.body = payload.to_json
    
              res = http.request(req)
              status = res.code.to_i
              return JSON.parse(res.body) if status < 400
    
              if [429, 500, 502, 503, 504].include?(status) && attempt < max_attempts
                sleep [5, 0.25 * (2 ** (attempt - 1))].min
                next
              end
    
              body = begin
                JSON.parse(res.body)
              rescue JSON::ParserError
                {}
              end
              err = body['error'] || {}
              raise Error.new(
                code: err['code'] || 'unknown',
                message: err['message'] || "HTTP #{status}",
                status: status,
                request_id: err['request_id'],
              )
            rescue Net::OpenTimeout, Net::ReadTimeout, SocketError => e
              last_err = e
              if attempt < max_attempts
                sleep [5, 0.25 * (2 ** (attempt - 1))].min
                next
              end
              raise Error.new(code: 'network_error', message: e.message, status: 0)
            end
          end
        end
      end
    end
  2. Send from a controller

    Inline send is fine for low-volume responses. Anything user-facing should queue (next recipe).

    # app/controllers/contacts_controller.rb
    class ContactsController < ApplicationController
      def create
        permitted = params.require(:contact).permit(:name, :email, :message)
        Sndr::Client.send_email(
          from: Rails.application.credentials.dig(:sndr, :from),
          to: 'team@yourdomain.com',
          reply_to: permitted[:email],
          subject: "New contact from #{permitted[:name]}",
          text: permitted[:message],
        )
        render json: { ok: true }
      rescue Sndr::Error => e
        render json: { error: { code: e.code, message: e.message } }, status: e.status
      end
    end
  3. Queue via ActiveJob (recommended)

    Transactional sends should survive a request failure. ActiveJob with retry_on gives you that. Pair with a stable per-resource idempotency key.

    # app/jobs/send_transactional_email_job.rb
    class SendTransactionalEmailJob < ApplicationJob
      queue_as :transactional
    
      retry_on Sndr::Error, wait: :polynomially_longer, attempts: 5
    
      def perform(payload, idempotency_key:)
        Sndr::Client.send_email(payload, idempotency_key: idempotency_key)
      end
    end
    
    # Dispatch from anywhere:
    SendTransactionalEmailJob.perform_later(
      {
        from: Rails.application.credentials.dig(:sndr, :from),
        to: user.email,
        subject: 'Order confirmation',
        html: OrderMailer.render_template('confirmation', order: order),
      },
      idempotency_key: "order-#{order.id}",
    )
  4. Verify an incoming webhook

    Webhook controllers must skip CSRF (they're not browser-initiated). Compare HMACs with ActiveSupport::SecurityUtils.secure_compare for constant time.

    # config/routes.rb
    Rails.application.routes.draw do
      post '/webhooks/sndr', to: 'webhooks#sndr'
    end
    
    # app/controllers/webhooks_controller.rb
    class WebhooksController < ActionController::API
      # ActionController::API skips Rails' default CSRF middleware by default.
      # If you inherit from ApplicationController instead, add:
      #   skip_before_action :verify_authenticity_token
    
      def sndr
        raw = request.raw_post
        header = request.headers['X-Sndr-Signature'].to_s
    
        t, v1 = parse_signature(header)
        return head :bad_request if t.nil? || v1.nil?
    
        # 5-minute freshness check (replay defense).
        return head :bad_request if (Time.now.to_i - t.to_i).abs > 300
    
        secret = Rails.application.credentials.dig(:sndr, :webhook_secret)
        expected = OpenSSL::HMAC.hexdigest('sha256', secret, "#{t}.#{raw}")
        return head :bad_request unless ActiveSupport::SecurityUtils.secure_compare(expected, v1)
    
        event = JSON.parse(raw)
        case event['type']
        when 'email.delivered'
          # event['data']: { 'email_id' => ..., 'to' => [...] }
        when 'email.bounced'
          # event['data']: { 'email_id' => ..., 'error' => ... }
        when 'email.unsubscribed'
          # event['data']: { 'addresses' => [...] }
        end
    
        head :ok
      end
    
      private
    
      def parse_signature(header)
        t = nil
        v1 = nil
        header.split(',').each do |part|
          eq = part.index('=')
          next if eq.nil?
          k = part[0...eq].strip
          v = part[(eq + 1)..].strip
          t = v if k == 't'
          v1 = v if k == 'v1'
        end
        [t, v1]
      end
    end
  5. Optional — call from ActionMailer

    If you want to keep ActionMailer's template + preview ergonomics, override perform_deliveries to route through SNDR. This is a per-mailer choice — most teams ship transactional sends via the service object above and reserve ActionMailer for marketing-style templated mail.

    # app/mailers/application_mailer.rb
    class ApplicationMailer < ActionMailer::Base
      default from: -> { Rails.application.credentials.dig(:sndr, :from) }
      layout 'mailer'
    end
    
    # config/initializers/sndr_delivery.rb
    class SndrDelivery
      def initialize(settings = {})
        @settings = settings
      end
    
      def deliver!(mail)
        Sndr::Client.send_email(
          from: mail.from.first,
          to: mail.to,
          subject: mail.subject,
          html: mail.html_part&.body&.to_s,
          text: mail.text_part&.body&.to_s,
          reply_to: mail.reply_to&.first,
          headers: mail.header_fields.map { |h| [h.name, h.value.to_s] }.to_h,
        )
      end
    end
    
    ActionMailer::Base.add_delivery_method :sndr, SndrDelivery
    
    # config/environments/production.rb
    config.action_mailer.delivery_method = :sndr

Ruby on Rails FAQ

Is there an official Ruby gem?
Not yet. The HTTP API is small enough that a single service object covers send + retry + error handling using only standard-library Net::HTTP. If a community gem appears we will link to it from the docs.
Why ActiveJob instead of inline send?
Inline sends couple your response latency to the upstream API. ActiveJob with a retry_on directive keeps responses fast and lets transient failures recover automatically. Pair with a stable idempotency key so retries never double-deliver.
Should I use ActionMailer or the service object?
ActionMailer is great for template rendering and previews. The service object is better for non-template sends and gives you direct access to idempotency keys and structured error codes. The recipe above lets you have both — keep ActionMailer's UX, route delivery through SNDR.
How do I avoid leaking the API key into Rack logs?
Rails 7+ filters Authorization headers by default. Confirm by checking config.filter_parameters in your environment files; add :authorization if missing.

Send your first Ruby on Rails email.

3,000 emails a month on the Free plan. No credit card needed.