Docs Go to app →

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.

Packageletterapp on RubyGems
LicenseMIT
RuntimeRuby 3.0+
SizeZero 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

OptionDefaultWhat it does
api_key-Required. lt_live_... from Settings.
base_urlhttps://api.letter.appOverride for self-hosting.
flush_at50Send a batch when this many items are queued.
flush_interval0.1 (seconds)Send queued items at most this often.
max_retries3Retry attempts on 5xx and 429.
open_timeout10 (seconds)Connection open timeout.
read_timeout10 (seconds)Response read timeout.
on_errorwarns to stderrCalled 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. An at_exit hook 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: wait Retry-After seconds, then retry (up to max_retries).
  • 5xx or network errors: exponential backoff (0.25s x 2^attempt + jitter).
  • 4xx other than 429: raised immediately (via on_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.