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
# 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'# 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.shRecipes
The patterns you actually need.
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 endSend 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 endQueue 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}", )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 endOptional — 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?
Why ActiveJob instead of inline send?
Should I use ActionMailer or the service object?
How do I avoid leaking the API key into Rack logs?
Send your first Ruby on Rails email.
3,000 emails a month on the Free plan. No credit card needed.