How to send email from Rails with an API, a service object, Active Job and a webhook
Published Updated 10 min readBy the Rasket team

Action Mailer over SMTP vs an API
To send email from Rails you either write a mailer class and let Action Mailer deliver it over SMTP to a server you configure, or you make one HTTPS request to an email API from a job. Both put a message in front of a mail server. What differs is what happens before and after that handoff, and which of it is yours to build.
| An HTTP API | Action Mailer over SMTP | |
|---|---|---|
| Installation | Standard library | Built in |
| Configuration | A key in the environment | smtp_settings: host, port, credentials, TLS |
| Round trips | One | Several, by design |
| What you get back | A message id to store | Nothing you can look up later |
| MIME assembly | Not yours | Mailer views and the mail gem |
| Domain authentication | Generated and checked for you | Yours to publish and watch |
| Suppressions | Kept and enforced at send time | None |
| Bounces | Typed events on a webhook | Messages you parse |
| Testing | Stub one HTTP call | The :test delivery method |
| Best for | Product mail you have to account for | A relay you already run, or previews and tests in development |
Action Mailer is a good piece of software. Mailer previews, the :test delivery method and deliver_later are all things you will miss elsewhere. What it cannot do is remember that an address bounced last week, refuse a send from a domain that fails authentication, or tell you a message was delivered — SMTP does not carry that information back. The API versus SMTP comparison goes through the trade in full, and the Rails stack page is the short version of this guide. The rest of it is the Rails email API integration end to end: the request, the class, the job and the webhook.
A plain Net::HTTP client
There is no gem to install. This is the sample the stack page shows, unchanged, and it is the whole API: a bearer token, three headers and a JSON body.
# app/services/shipping_mailer.rb# net/http and json are in the standard library.require "net/http"require "json"
uri = URI("https://api.rasket.com/emails")key = ENV.fetch("RASKET_API_KEY")
request = Net::HTTP::Post.new(uri)request["Authorization"] = "Bearer #{key}"# Required: a request with no User-Agent is# refused before it reaches your account.request["User-Agent"] = "acme-billing/1.0"request["Content-Type"] = "application/json"request["Idempotency-Key"] = "order-1042"request.body = { from: "Acme <orders@send.acme.example>", to: ["ronald.williams@example.com"], subject: "Your order has shipped", html: "<p>Order 1042 shipped today.</p>"}.to_json
response = Net::HTTP.start( uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
unless response.is_a?(Net::HTTPSuccess) raise "Rasket answered #{response.code}"end
puts JSON.parse(response.body)["id"]Three lines carry the weight. ENV.fetch rather than ENV[] raises a KeyError at boot when the variable is missing, instead of sending an empty bearer token from a worker at three in the morning. The User-Agent is required — a request without one is refused — and what you send is what lets support tell your billing worker from your web process. And the Idempotency-Key is derived from the order, which is the thing that caused the send. A repeat of a keyed request inside 24 hours returns the original response and sends nothing; the idempotency guide has the exact rules.
- Add and verify a sending domain — Add the domain the mail will come from, publish the DNS records Rasket generates for it, and wait for verification to pass. A send from an unverified domain is refused.
- Require the standard library, and one gem for webhooks — Net::HTTP and JSON ship with Ruby, so the send needs no gem. Add gem "svix" to the Gemfile for the webhook controller at the end of the guide.
- Put the key in the environment — Create an API key in the dashboard and set it as RASKET_API_KEY. Read it with ENV.fetch, which raises at boot when the variable is missing, and never with a literal in the code.
- Make the request from a service object — Build a Net::HTTP::Post with the bearer token, a User-Agent and an Idempotency-Key taken as an argument, set timeouts, and raise a typed error on anything but a 2xx.
- Send from an Active Job — Call the service object from perform with the same idempotency key on every attempt, retry_on the failures where nothing answered, and discard_on the ones where the API said no.
- Receive delivery events in a controller — Add a controller that calls skip_forgery_protection, verify request.raw_post with the svix gem before reading params, answer head :ok at once, and record the event from a job keyed on svix-id.
The from address has to be on a verified domain
A send from a domain that has not passed verification is refused before anything leaves. Publishing the records takes a few minutes and the verification walkthrough covers the panel behaviour that trips most people up.
Wrapping it in a service object
A script is fine for a proof. An application wants the request in one place, with timeouts, a typed error and the key as an argument rather than a literal.
# app/services/rasket_client.rbrequire "net/http"require "json"
class RasketClient # The API answered and said no. status, name and message are the # error body's own fields. class ApiError < StandardError attr_reader :status, :name, :retry_after
def initialize(status, body, retry_after) @status = status @name = body["name"] @retry_after = retry_after super("Rasket answered #{status} #{@name}: #{body["message"]}") end end
class RateLimited < ApiError; end
BASE = URI("https://api.rasket.com") USER_AGENT = "acme-billing/1.0"
def initialize(key: ENV.fetch("RASKET_API_KEY")) @key = key end
# The caller decides what counts as the same send, so the key is # an argument here rather than something this class invents. def send_email(message, idempotency_key:) request = Net::HTTP::Post.new(BASE.merge("/emails")) request["Authorization"] = "Bearer #{@key}" request["User-Agent"] = USER_AGENT request["Content-Type"] = "application/json" request["Idempotency-Key"] = idempotency_key request.body = message.to_json
response = Net::HTTP.start( BASE.hostname, BASE.port, use_ssl: true, open_timeout: 5, read_timeout: 10 ) { |http| http.request(request) }
body = JSON.parse(response.body) return body if response.is_a?(Net::HTTPSuccess)
retry_after = response["retry-after"]&.to_i error = response.code == "429" ? RateLimited : ApiError raise error.new(response.code.to_i, body, retry_after) endend- Timeouts.
Net::HTTP.startwithoutopen_timeoutandread_timeoutcan wait a long time, and in a job that means a worker that never finishes rather than an error you can act on. Ruby’sNet::HTTPdocumentation lists both. - One error class, and one subclass. The API answers every failure with
statusCode,nameandmessage, so the exception carries those. A429is the one 4xx that changes on retry, so it gets its own class and theretry-afterheader, in whole seconds, comes along. The limit is ten requests a second per team, shared by every key; the rate limits guide has the headers. - The key is an argument. The client does not know whether two calls are the same send. The caller does, so the caller says so.
email = RasketClient.new.send_email( { from: "Acme <orders@send.acme.example>", to: ["ronald.williams@example.com"], subject: "Your order has shipped", html: "<p>Order 1042 shipped today.</p>" }, idempotency_key: "order-1042")
email["id"] # => the id to store against the orderStore email["id"] against the order. It is what every later event is about, and the value you quote in a support conversation.
Sending from a background job
A send is a network call to another service. Doing it inside a controller action makes your response time depend on theirs, and your request fail when theirs does. Active Job already has retries and backoff; the only thing to get right is which failures to retry, and with what key.
# app/jobs/shipping_email_job.rbclass ShippingEmailJob < ApplicationJob queue_as :mail
# Handlers are matched last-declared first, so the general case # goes first and the two exceptions to it follow. discard_on RasketClient::ApiError # a 4xx fails the same way next time
retry_on RasketClient::RateLimited, wait: 2.seconds, attempts: 10
# Nothing answered. Retry with backoff; the key below makes it safe. retry_on Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, wait: :polynomially_longer, attempts: 5
def perform(order_id) order = Order.find(order_id)
email = RasketClient.new.send_email( { from: "Acme <orders@send.acme.example>", to: [order.email], subject: "Your order has shipped", html: ApplicationController.render( template: "orders/shipped", assigns: { order: order }, layout: false ) }, # The SAME key on every attempt of this order. idempotency_key: "order-#{order.id}" )
order.update!(shipping_email_id: email["id"]) endendThe three declarations are the whole retry policy. A timeout or a reset connection means nothing answered, so you do not know whether the message went: retry it, with :polynomially_longer spacing the attempts out. A RateLimited error means wait a moment and try again. Any other ApiError means the API answered and said no — a validation error will say no identically forever — so the job discards it and lets the error tracker hear about it. The Active Job guide documents retry_on and discard_on, including the order in which handlers are matched.
The comment inside the call is the reason the retries are safe. Every attempt sends order-1042 as its key, so an attempt that timed out after the API had in fact accepted the message gets the original response back rather than a second email. Without that, backoff is a machine for sending duplicates politely.
Enqueueing it
ShippingEmailJob.perform_later(order.id) from the controller or a model callback is all it takes. Pass the id rather than the record, and enqueue after the transaction commits — after_commit, or the enqueue_after_transaction_commit setting on Rails 7.2 and later — so a fast worker does not look up an order that is not there yet.
Webhooks in a controller
A send returns 200 when the message is accepted and recorded, not when it is delivered. Delivery, bounces, complaints and opens arrive later as signed HTTPS requests to a URL you register. In Rails that URL is a controller action with three properties the framework will fight you on.
# app/controllers/hooks/rasket_controller.rb — gem "svix"module Hooks class RasketController < ApplicationController # No session and no CSRF token: the signature is the authentication. skip_forgery_protection
def create # raw_post is the body as it arrived. Do not touch params first: # the signature is over those bytes, not a re-serialised hash. secret = ENV.fetch("RASKET_WEBHOOK_SECRET") event = Svix::Webhook.new(secret).verify(request.raw_post, request.headers)
# Answer first, work later. The job dedupes on svix-id. RecordEventJob.perform_later(request.headers["svix-id"], event) head :ok rescue Svix::WebhookVerificationError head :bad_request end endend- It skips forgery protection. Rails rejects any POST without an authenticity token it issued, and a webhook sender has none.
skip_forgery_protectionturns the check off for this controller; the signature is the authentication instead. - It reads
request.raw_postfirst. The signature is an HMAC over the raw bytes. Readingparamsparses the body, and re-serialising a hash produces a different string with a different signature. Verify the bytes, then use the parsed event the verifier returns. - It answers fast. A handler that does its work inline and times out will be sent the same event again. Hand the event to a job keyed on
svix-id, answerhead :ok, and let the job dedupe.
Add gem "svix" to the Gemfile, route it with post "hooks/rasket", to: "hooks/rasket#create" and register the same URL in the dashboard. The verifier checks the timestamp as well as the digest, so a request replayed more than five minutes later is refused too. The webhooks article covers retries, ordering and replay, and the events reference lists every event a send can produce.
Frequently asked questions
Can I keep Action Mailer and only change the delivery method?
Not to the API directly: a delivery method speaks SMTP or hands the message to a gem, and this guide uses neither. The request replaces the mailer for the send. You can keep mailer views and previews for the HTML, because ApplicationController.render or a mailer's own template can produce the string the request carries.
Why not use a gem for the API?
Because the API is one request, and the standard library already makes it well. Net::HTTP is in every Ruby and is one fewer thing to upgrade. A client class of forty lines gives you timeouts, a typed error and one place the user agent is set, which is most of what a gem would give you.
Which errors should the job retry?
Retry a timeout or a reset connection, because nothing answered and you do not know whether the message went. Retry a 429 after the number of seconds in its retry-after header. Discard any other 4xx: a validation error will fail identically on every attempt, so retrying it only delays the alert.
What happens if the job retries after the API already accepted the message?
Nothing is sent twice, provided every attempt carries the same idempotency key. The API remembers a key for 24 hours and answers a repeat with the original response, so the retry gets the first email's id back. That is why the key is derived from the order rather than generated inside the job.
Why does my webhook controller answer 422?
Rails rejects a POST without an authenticity token it issued, and in production that InvalidAuthenticityToken error renders as a 422. A webhook sender has no token, so the controller calls skip_forgery_protection and lets the signature authenticate the request instead. Check the route too: a typo there answers 404, not 422.
Where does the webhook secret come from, and can I rotate it?
The dashboard shows the secret when you register the endpoint; put it in RASKET_WEBHOOK_SECRET and read it with ENV.fetch like the API key. After a rotation the signature header carries two signatures for 24 hours, one per secret, and the verifier accepts a match on either, so you can update the variable at your own pace inside that window.
Sources
- Action Mailer Basics — Ruby on Rails Guides, read 2026-09-16
- Net::HTTP — Ruby documentation, read 2026-09-16
- Active Job Basics — Ruby on Rails Guides, read 2026-09-16
Related
- Send email from Rails with an API — Send email from Ruby on Rails over HTTPS instead of SMTP: one Net::HTTP call from a service object, an id back, delivery on a webhook.
- Emails — Send, batch, retrieve, list, reschedule, cancel, attachments.
- Idempotency — Retry a send without sending it twice.
- Email API vs SMTP: which should you use? — SMTP is a conversation; an email API is one request. What each gives you on retries, idempotency, events and firewalls, and how to move from one to the other.
- Webhooks — Every delivery, bounce, complaint, open and click posted to your endpoint, signed with a timestamp, retried on failure and replayable from the dashboard.