Ruby 3.1 or later.
Every sample uses the standard library unless it says otherwise. Faraday is optional; addfaraday to your Gemfile for that tab.
There is no Ruby SDK, and you do not need one: the send is a single JSON POST that Net::HTTP handles in twenty lines. This guide covers that call, the same send through Faraday, the SMTP relay for code that already speaks SMTP, a retry loop that cannot send twice, and a Rack handler that verifies delivery webhooks.
Every sample uses the standard library unless it says otherwise. Faraday is optional; addfaraday to your Gemfile for that tab.
The from-address must be on a domain you have verified in the dashboard. Keep the key in the environment; ENV.fetch fails loudly when it is missing.
The API tab is one request: a Bearer key, a JSON body, and an Idempotency-Key that names the message. The SMTP tab points the standard library at smtp.usenotix.dev with the username notix and your API key as the password; it lands in the same log and honours the same suppression list.
require "json"
require "net/http"
require "uri"
uri = URI("https://app.usenotix.dev/api/v1/emails")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("NOTIX_API_KEY")}"
request["Content-Type"] = "application/json"
# Same key on a retry, same message: the API answers with the original id.
request["Idempotency-Key"] = "order-4471-receipt"
request.body = {
from: "receipts@acme.com",
to: "customer@example.com",
subject: "Your receipt for order 4471",
html: "<p>Thanks for your order.</p>",
text: "Thanks for your order."
}.to_json
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(request)
end
body = JSON.parse(response.body)
if response.is_a?(Net::HTTPSuccess)
puts "queued #{body["emailId"]}"
else
# { "error": { "code": "RATE_LIMITED", "message": "..." } }
warn "#{response.code} #{body.dig("error", "code")}: #{body.dig("error", "message")}"
end
require "faraday"
require "json"
NOTIX = Faraday.new(url: "https://app.usenotix.dev") do |f|
f.request :json
f.response :json
f.headers["Authorization"] = "Bearer #{ENV.fetch("NOTIX_API_KEY")}"
end
def send_receipt(order_id, to)
response = NOTIX.post("/api/v1/emails") do |req|
req.headers["Idempotency-Key"] = "order-#{order_id}-receipt"
req.body = {
from: "receipts@acme.com",
to: to,
subject: "Your receipt for order #{order_id}",
templateId: "tpl_receipt_v4",
variables: { orderId: order_id.to_s }
}
end
return response.body["emailId"] if response.success?
error = response.body.fetch("error", {})
raise "Notix #{response.status} #{error["code"]}: #{error["message"]}"
end
require "net/smtp"
message = <<~MAIL
From: receipts@acme.com
To: customer@example.com
Subject: Your receipt for order 4471
Content-Type: text/plain; charset=UTF-8
Thanks for your order.
MAIL
# 587 with STARTTLS; 465 with implicit TLS works too (tls: true).
smtp = Net::SMTP.new("smtp.usenotix.dev", 587)
smtp.enable_starttls
smtp.start("acme.com", "notix", ENV.fetch("NOTIX_API_KEY"), :plain) do |session|
session.send_message(message, "receipts@acme.com", "customer@example.com")
end
Keep one idempotency key for the whole retry loop. A 429 tells you how long to wait in Retry-After; a 5xx is worth another attempt; a 4xx is a bug on your side and should not be retried.
require "json"
require "net/http"
require "uri"
# One key for the whole retry loop, so three attempts can only ever produce
# one email. A different body with the same key answers 409 NOT_UNIQUE.
def post_email(payload, idempotency_key, attempts: 3)
uri = URI("https://app.usenotix.dev/api/v1/emails")
attempts.times do |attempt|
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("NOTIX_API_KEY")}"
request["Content-Type"] = "application/json"
request["Idempotency-Key"] = idempotency_key
request.body = payload.to_json
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 15) do |http|
http.request(request)
end
return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
# 429 carries Retry-After; 5xx is worth a retry; 4xx is not.
retryable = response.code == "429" || response.code.start_with?("5")
raise "Notix #{response.code}: #{response.body}" unless retryable && attempt < attempts - 1
sleep((response["Retry-After"] || 2 ** attempt).to_i)
rescue Net::OpenTimeout, Net::ReadTimeout
raise if attempt == attempts - 1
sleep(2 ** attempt)
end
end
Notix signs every event: a hex HMAC-SHA256 over the millisecond timestamp, a dot, and the raw body, prefixed with v1=. Read the body before any middleware parses it, compare with Rack::Utils.secure_compare, and reject anything older than five minutes.
# config.ru: a Rack app that verifies and handles Notix webhooks.
require "json"
require "openssl"
require "rack"
SECRET = ENV.fetch("NOTIX_WEBHOOK_SECRET") # starts with whsec_
TOLERANCE_MS = 5 * 60 * 1000
app = lambda do |env|
request = Rack::Request.new(env)
return [404, {}, ["not found"]] unless request.post? && request.path == "/webhooks/notix"
raw_body = request.body.read # the exact bytes, never re-encoded
timestamp = request.get_header("HTTP_X_NOTIX_TIMESTAMP").to_s
signature = request.get_header("HTTP_X_NOTIX_SIGNATURE").to_s
# Replay protection: the timestamp is milliseconds since the epoch.
now_ms = (Time.now.to_f * 1000).to_i
return [400, {}, ["stale"]] if (now_ms - timestamp.to_i).abs > TOLERANCE_MS
expected = "v1=" + OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{timestamp}.#{raw_body}")
return [400, {}, ["bad signature"]] unless Rack::Utils.secure_compare(expected, signature)
event = JSON.parse(raw_body)
case event["type"]
when "email.delivered" then Orders.mark_receipt_delivered(event.dig("data", "id"))
when "email.bounced" then Users.mark_email_invalid(event.dig("data", "to"))
end
[200, { "content-type" => "text/plain" }, ["ok"]]
end
run app
The Node.js guide walks through idempotency, the error envelope and webhooks in full; the contract is identical from Ruby. Rails users should read the Rails guide, where ActionMailer and Devise come into it.
Set open_timeout and read_timeout on Net::HTTP. A send normally completes in well under a second; a hung socket should fail fast and retry with the same key.
Sidekiq and Active Job retry on exceptions. Because the key is deterministic, a retried job returns the original id instead of a second email.
A permanent bounce suppresses the address automatically. Handle email.bounced to mark the user's address invalid in your own database; see bounce handling.
The full request and response shapes are in the API reference.
One JSON API for transactional and marketing email: send, batch, schedule, webhooks, typed SDKs.
GuidesActionMailer through the SMTP relay for password resets and Devise mail, or the JSON API for templates, plus a webhook controller with the raw body.
ProductPoint any framework's mailer at one host and port; the relay turns SMTP into the same tracked, suppressed send.
LearnWhy a retried request must not send twice, how the Idempotency-Key header works, and how to choose a key.
LearnHMAC-SHA256 over the timestamp and raw body, a five-minute tolerance, replay protection, and the check in four languages.
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.