Notix
Guides

Send email from Ruby.

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.

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.

A verified domain and an API key.

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.

Steps

From nothing to a queued receipt.

  1. Send with Net::HTTP, Faraday or the relay.

    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.

    send.rb, standard library only
    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
    
  2. Retry without sending twice.

    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.

    lib/notix.rb
    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
    
  3. Receive delivery status on a webhook.

    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
    # 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
    
In production

The same three things every runtime needs.

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.

Timeouts.

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.

Background jobs.

Sidekiq and Active Job retry on exceptions. Because the key is deterministic, a retried job returns the original id instead of a second email.

Bounces.

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.

Questions

Ruby, answered.

Is there a Ruby SDK for Notix?
No. Notix publishes SDKs for TypeScript, Python, PHP and Go. From Ruby you call the JSON API directly: one POST to /api/v1/emails with a Bearer key, and the same Idempotency-Key header and error envelope the SDKs use. Net::HTTP from the standard library is enough; Faraday is nicer once you have more than one endpoint to call.
Should I use the API or the SMTP relay from Ruby?
Use the relay when the code already speaks SMTP, such as a legacy mailer or a library that only knows how to talk to a host and port. Use the API when you want templates with variables, an idempotency key on retries, or attachments as JSON. Both write to the same message log and honour the same suppression list.
How do I stop a retried request from sending twice?
Send an Idempotency-Key header with a value that names the message, for example order-4471-receipt. A retry with the same key and body returns the original emailId within 24 hours instead of sending again; the same key with a different body answers 409 NOT_UNIQUE. Keys are up to 256 characters.
What does an error response look like?
A JSON object with an error field carrying a code and a message, for example { "error": { "code": "RATE_LIMITED", "message": "..." } }. A 429 also carries a Retry-After header. Retry on 429 and 5xx with the same idempotency key; do not retry a 400, 401, 403 or 422.
How do I verify a Notix webhook in Ruby?
Read the raw request body, take the X-Notix-Timestamp and X-Notix-Signature headers, compute a hex HMAC-SHA256 of "timestamp.rawBody" with your webhook secret, prefix it with v1=, and compare it to the signature with Rack::Utils.secure_compare. Reject timestamps more than five minutes from now. Never re-serialise the body before hashing.
Can I send with a template and variables from Ruby?
Yes. Pass templateId and a variables hash instead of subject, html and text. The template is built in the Notix editor and versioned there, so the Ruby side only supplies the values.

Send your first email in five minutes.

Verify a domain, copy an API key, make one call. The free plan does not ask for a card.