Send email from Ruby on Rails with an email 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.
# 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"]What sending from Ruby on Rails actually involves
To send email from Ruby on Rails you would normally configure Action Mailer with an SMTP host. Posting to an email API instead removes that configuration: one request from a service object or a job, and an id you can store on the record. There is no Ruby SDK, so the sample below uses Net::HTTP from the standard library — nothing to add to your Gemfile.
Last updated 2026-10-13. Every field, header and limit below is the one the emails reference carries.
What you get with the key
Five things that arrive with the first request rather than with a later plan.
Idempotent sends
Put an Idempotency-Key on a send and repeat it as often as you like. For 24 hours the same key and payload return the first response instead of a second email, and a different payload under that key is refused rather than sent.
Signed webhooks you can replay
Delivery, bounce, complaint, open and click posted to your endpoint with a timestamp and a signature, retried on failure and replayable from the dashboard.
Inbound mail on your own domain
Receive at your domain on every plan. Each message arrives on a signed webhook with its headers, text and HTML, and its attachments behind signed links.
Templates with typed variables
Publish a template, then send it by ID or alias with the values it declares. Every email records the exact version it was rendered from.
Campaigns and automations
Contacts with typed properties, topics people subscribe to, and per-contact workflows that wait, branch and send — through the same pipeline as the rest of your mail.
How to send email from Ruby on Rails
Six steps, in this order. Everything before the fifth is done once, and only the fifth is about the language you write in.
- 1
Create an API key
Open API keys in the dashboard and create one. It is shown once and stored hashed, so copy it then. Put it in your environment as RASKET_API_KEY and read it from there; a key committed to a repository is a key you have to rotate.
- 2
Add a sending domain
POST /domains with a domain you control. A subdomain such as send.acme.example is the usual choice, because it keeps this mail's reputation separate from the address your people write from. The response carries a records array.
- 3
Publish the DNS records
Every row of records goes into your DNS: the DKIM TXT record at rasket._domainkey, and the CNAME that gives the domain its own return path. Nothing sends from the domain until they resolve.
- 4
Verify the domain
POST /domains/{domain_id}/verify. The domain comes back with a status on each record, and once it reads verified you can send from any address on it. An unverified sender is refused rather than quietly dropped.
- 5
Send your first email from Ruby on Rails
Post the message with the sample above. Carry an Idempotency-Key — yours or your queue's retry cannot then turn into a second email — and a User-Agent, which every request has to have. A 200 with an id means we have taken responsibility for the message.
- 6
Subscribe a webhook and check its signature
POST /webhooks with an https endpoint and the events you care about. Delivery, bounce, complaint, open and click arrive there with svix-id, svix-timestamp and svix-signature; verify against the raw body before anything parses it.
Verify webhooks in Ruby on Rails
request.raw_post is the body as it arrived; read it before params or any JSON middleware touches the request, and rewind the stream if something later needs it. The svix gem's Webhook#verify takes those bytes and request.env. A controller that reads params first has already parsed, and the signature is over the original string.
The five steps a verifier performs, in seven languages, are on the webhooks reference.
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.
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.
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
Questions about Ruby on Rails
Do I need an SMTP server to send email from Rails?
No. An HTTPS API replaces Action Mailer's delivery layer: no host, no port, no TLS settings. An app that must keep an :smtp delivery method can point it at smtp.rasket.com on port 465 with tls: true, the username rasket and the key, and every message still becomes an API send.
Is there a Ruby SDK?
Not today. The sample above uses Net::HTTP and JSON from the standard library, which is the whole of what a send needs: a bearer token, a User-Agent, a JSON body and one header for safe retries. The API reference documents every field the body can carry.
Where do I keep the API key in a Rails app?
In the environment, read as ENV.fetch("RASKET_API_KEY") — fetch rather than [], so a missing key raises at boot instead of sending a request with an empty bearer. Rails credentials work too; what matters is that the value is not in the repository.
How do I stop a retry from sending the same email twice?
Set the Idempotency-Key header from the record the mail is about, and reuse it on every retry. Active Job retries are exactly the case this covers: for 24 hours the same key with the same payload returns the first response rather than sending a second message.
How do I verify a Rasket webhook in Rails?
gem install svix, read request.raw_post as the first line of the action, and pass it with request.env to Svix::Webhook#verify. Skip the CSRF check on that action — the request comes from us, and the signature is what authenticates it — and answer 400 when verification raises.
Can I keep Action Mailer and only change the delivery method?
Yes, through the SMTP relay: set the :smtp delivery method to smtp.rasket.com on port 465 with tls: true, the username rasket and your key. This guide goes one step further and replaces the mailer with one request, which returns the message id. Either way 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.
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.
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.
Keep reading
Make your first send
Create a key, verify a domain, and post your first message from Ruby on Rails. The free plan does not expire.