Ruby SDK
letterapp is the official Ruby client. It batches ingestion events on a
background thread, retries on transient failures, and generates idempotency
keys for you - so the common letter.track(...) call from a request handler is
non-blocking and safe to retry. It also sends transactional email, either
directly or as an ActionMailer delivery method.
If you’d rather call the HTTP API directly, see the Ingestion API and Transactional email.
| Package | letterapp on RubyGems |
| License | MIT |
| Runtime | Ruby 3.0+ |
| Size | Zero runtime dependencies (standard library only) |
Install
bundle add letterapp
# or
gem install letterapp
The gem has no runtime dependencies - HTTP goes over the standard library
net/http, JSON over json. Works in Rails, Sinatra, Sidekiq workers, and
plain scripts.
You’ll need an API key from Dashboard - Settings - API keys before the SDK will do anything useful.
Quick start
require "letterapp"
letter = Letterapp::Client.new(api_key: ENV["LETTER_API_KEY"])
letter.identify(
user_id: user.id,
email: user.email,
traits: { name: user.name, plan: "free" }
)
letter.group(
user_id: user.id,
account_id: workspace.id,
name: workspace.name,
traits: { plan: workspace.plan, mrr: 49 }
)
letter.track(
user_id: user.id,
event: "Workspace Created",
properties: { workspace_id: workspace.id }
)
# Required before the process exits on long-running servers.
letter.close
Create the api_key in Dashboard - Settings - API keys. It’s shown once
on creation and never again - store it somewhere safe.
In Rails, create the client once in an initializer
(config/initializers/letter.rb) and reuse it across requests.
Constructor options
| Option | Default | What it does |
|---|---|---|
api_key | - | Required. lt_live_... from Settings. |
base_url | https://api.letter.app | Override for self-hosting. |
flush_at | 50 | Send a batch when this many items are queued. |
flush_interval | 0.1 (seconds) | Send queued items at most this often. |
max_retries | 3 | Retry attempts on 5xx and 429. |
open_timeout | 10 (seconds) | Connection open timeout. |
read_timeout | 10 (seconds) | Response read timeout. |
on_error | warns to stderr | Called when a background flush fails. |
Methods
-
identify(user_id:, email:, traits:, timezone:, timestamp:, message_id:) -
group(user_id:, account_id:, name:, traits:, timestamp:, message_id:) -
track(user_id:, event:, properties:, timestamp:, message_id:)These enqueue and return immediately. Transport errors surface via
on_error. Fast path for long-running servers. -
send_email(to:, subject:, html:, text:, from:, from_name:, reply_to:, headers:, tag:, metadata:, idempotency_key:)- one transactional email, sent immediately. See below. -
flush- send everything queued now; blocks until the request settles. -
close- flush, stop the background thread, refuse new enqueues. Anat_exithook runs it automatically, but call it explicitly at shutdown so no events are lost.
Transactional email
send_email mails one person right now: a receipt, a password reset, a
verification link. It is never batched and unaffected by flush_at: - batching
a password reset would be a bug, not an optimization.
result = letter.send_email(
to: "user@example.com",
subject: "Reset your password",
html: "<p>Click <a href='https://...'>here</a> to reset.</p>",
tag: "password-reset",
idempotency_key: "password-reset:#{token}"
)
result["messageId"] # provider id, appears in delivery events
result["replayed"] # true if an idempotency-key replay returned an earlier send
It’s send_email and not send because Object#send is Ruby’s dynamic
dispatch; shadowing it on the client would break metaprogramming in the host
app.
Only to:, subject: and one of html: / text: are required. from:
defaults to the project’s sender and must be on a verified domain. A
plain-text part is derived from the HTML when you don’t supply one. The full
option list and the suppression rules are in
Transactional email.
Failures raise Letterapp::Error carrying #status, #code and #reason.
The reason is what tells “this recipient is unreachable” apart from “our
account is blocked”:
begin
letter.send_email(to: to, subject: subject, html: html)
rescue Letterapp::Error => e
# Hard-bounced or reported spam. Nothing to retry, nothing to fix.
raise unless e.reason == "suppressed"
end
Rails: ActionMailer
If the app already has mailers, don’t rewrite them. The gem registers a
:letter delivery method, so switching providers is config only:
# config/environments/production.rb
config.action_mailer.delivery_method = :letter
config.action_mailer.letter_settings = { api_key: ENV["LETTER_API_KEY"] }
Every mailer, view, and deliver_later keeps working. HTML and text parts map
straight through. A message with several recipients becomes one send each, so
everyone gets their own log row and bounce.
To make a delivery replay-safe, set the key in the mailer:
headers["X-Letter-Idempotency-Key"] = "password-reset:#{token}"
Attachments raise rather than being dropped silently, since the transactional API doesn’t carry them — a receipt that arrives without its PDF looks delivered but isn’t.
Retry behavior
429: waitRetry-Afterseconds, then retry (up tomax_retries).5xxor network errors: exponential backoff (0.25s x 2^attempt + jitter).4xxother than 429: raised immediately (viaon_error), no retry.
The SDK auto-generates a UUID message_id per ingestion call, so retries dedupe
at the server. See Idempotency for the underlying
guarantee.
send_email is the exception: it only retries when you pass an
idempotency_key:. Without one, a retry after a timeout could put a second
copy of the email in someone’s inbox, which is worse than failing the call.
Serverless mode
In serverless / function environments there’s no background time between
requests to drain the queue, so set flush_at: 1 and call flush at the end
of each handler:
letter = Letterapp::Client.new(api_key: ENV["LETTER_API_KEY"], flush_at: 1)
def handler(event:, context:)
letter.track(user_id: user_id, event: "Checkout Started")
letter.flush
end
Errors
Configuration errors and non-retryable API responses raise Letterapp::Error
(carrying #status, #code, #reason and #body), which uses the same shape
as the HTTP API - see Error format. Background
transport errors are passed to on_error instead, since they can’t be raised
to the caller.
Versioning
The SDK follows semver. While we’re at 0.x:
- patch (
0.1.0 -> 0.1.1) - bug fixes only. - minor (
0.1.0 -> 0.2.0) - new options, new methods, behavior changes. - major (
0.x -> 1.0.0) - only once the HTTP API and signatures are stable. Until then, pin a pessimistic range (gem "letterapp", "~> 0.1").
Every request sends a User-Agent: letterapp-ruby/<version> header so we can
spot outdated clients in server logs.