Rails 7.1 or later.
The smtp_settings keys below are the ones the Rails 8.0 guide documents. Older versions accept the same hash; enable_starttls_auto is the older name for the TLS flag.
Point ActionMailer at the relay and every mailer you already have, Devise included, sends through Notix without a code change. Add a small service object for the sends that need a template and an idempotency key, and a controller that verifies delivery webhooks.
The smtp_settings keys below are the ones the Rails 8.0 guide documents. Older versions accept the same hash; enable_starttls_auto is the older name for the TLS flag.
Every from-address must be on a domain verified in the dashboard. The key and the webhook secret go in the encrypted credentials file, never in a committed environment file.
# bin/rails credentials:edit
notix:
api_key: notix_xxxxxxxxxxxx
webhook_secret: whsec_xxxxxxxxxxxx
smtp.usenotix.dev on 587 with STARTTLS, the username notix, the API key as the password. Devise, password resets and every mailer view you already have now go through Notix, with the same log, suppression and webhooks as the API.
# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: "smtp.usenotix.dev",
port: 587,
domain: "acme.com",
user_name: "notix",
password: Rails.application.credentials.dig(:notix, :api_key),
authentication: "plain",
enable_starttls: true,
open_timeout: 5,
read_timeout: 5
}
config.action_mailer.default_options = { from: "receipts@acme.com" }
config.action_mailer.raise_delivery_errors = true
# app/mailers/order_mailer.rb
class OrderMailer < ApplicationMailer
def receipt
@order = params[:order]
mail(to: @order.customer_email, subject: "Your receipt for order #{@order.number}")
end
end
# Anywhere in the app: queued through Active Job, delivered over the relay.
OrderMailer.with(order: order).receipt.deliver_later
When the template lives in Notix or the send must never go out twice, call the API from a service object and give the job an idempotency key derived from the record.
# app/services/notix.rb
require "net/http"
require "json"
class Notix
Error = Class.new(StandardError)
BASE = URI("https://app.usenotix.dev")
def initialize(api_key: Rails.application.credentials.dig(:notix, :api_key))
@api_key = api_key
end
# The idempotency key makes a retried job return the original id.
def send_email(payload, idempotency_key:)
request = Net::HTTP::Post.new("/api/v1/emails")
request["Authorization"] = "Bearer #{@api_key}"
request["Content-Type"] = "application/json"
request["Idempotency-Key"] = idempotency_key
request.body = payload.to_json
response = Net::HTTP.start(BASE.host, BASE.port, use_ssl: true, open_timeout: 5, read_timeout: 15) do |http|
http.request(request)
end
body = JSON.parse(response.body)
return body["emailId"] if response.is_a?(Net::HTTPSuccess)
error = body.fetch("error", {})
raise Error, "#{response.code} #{error["code"]}: #{error["message"]}"
end
end
# app/jobs/send_receipt_job.rb
class SendReceiptJob < ApplicationJob
queue_as :mailers
retry_on Notix::Error, wait: :polynomially_longer, attempts: 5
def perform(order)
email_id = Notix.new.send_email(
{
from: "receipts@acme.com",
to: order.customer_email,
templateId: "tpl_receipt_v4",
variables: { orderNumber: order.number, total: order.total.format }
},
idempotency_key: "order-#{order.id}-receipt"
)
# Keep the id next to the order: every webhook event carries it.
order.update!(receipt_email_id: email_id)
end
end
Skip forgery protection on the controller, read request.raw_post rather than the parsed params, and check the signature before touching the event.
# config/routes.rb
post "/webhooks/notix", to: "notix_webhooks#create"
# app/controllers/notix_webhooks_controller.rb
class NotixWebhooksController < ActionController::Base
skip_forgery_protection # Notix is not a browser session
TOLERANCE_MS = 5 * 60 * 1000
def create
raw = request.raw_post # the exact bytes; never re-serialise params
timestamp = request.headers["X-Notix-Timestamp"].to_s
signature = request.headers["X-Notix-Signature"].to_s
now_ms = (Time.now.to_f * 1000).to_i
return head :bad_request if (now_ms - timestamp.to_i).abs > TOLERANCE_MS
secret = Rails.application.credentials.dig(:notix, :webhook_secret)
expected = "v1=" + OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw}")
return head :bad_request unless ActiveSupport::SecurityUtils.secure_compare(expected, signature)
event = JSON.parse(raw)
case event["type"]
when "email.delivered"
Order.find_by(receipt_email_id: event.dig("data", "id"))&.update!(receipt_delivered_at: Time.current)
when "email.bounced"
User.where(email: event.dig("data", "to")).update_all(email_invalid: true) if event.dig("data", "bounce", "type") == "Permanent"
end
head :ok
end
end
The Ruby guide has the plain HTTP contract, a retry loop and a Rack handler; the Node.js guide covers the production checklist in full. The contract is the same from Rails.
Keep delivery_method = :test in test and :letter_opener or the log in development, so only production talks to the relay.
A permanent bounce suppresses the address automatically. The webhook above marks the user's email invalid so your UI can ask for a new one; see bounce handling.
Request and response shapes, error codes and every webhook event are in the API reference.
Point any framework's mailer at one host and port; the relay turns SMTP into the same tracked, suppressed send.
GuidesNet::HTTP or Faraday against the JSON API, or Net::SMTP against the relay, with an idempotency key and a Rack webhook handler.
Use casesA token link flow and an OTP variant, the template copy, and the limits and suppression rules that apply to resets.
LearnWhy a retried request must not send twice, how the Idempotency-Key header works, and how to choose a key.
ProductOne JSON API for transactional and marketing email: send, batch, schedule, webhooks, typed SDKs.
NotixA free plan with no card, and Pro at $15 a month for 50,000 emails. Only sent volume is metered.
Verify a domain, copy an API key, make one call. The free plan does not ask for a card.