Sendara Ruby

Email-first Ruby client for the Sendara API: transactional email, broadcasts, contacts, templates, domains, and signed webhooks. Pure stdlib HTTP, zero runtime dependencies.

Requires Ruby >= 3.0.

Installation

gem install sendara

Or with Bundler, add to your Gemfile:

gem "sendara"

Then run:

bundle install

Quickstart

require "sendara"

client = Sendara.new(ENV.fetch("SENDARA_API_KEY"))

result = client.emails.send(
  from: "you@yourdomain.com",
  to: "customer@example.com",
  subject: "Welcome to Acme",
  html: "<h1>Hello</h1><p>Thanks for signing up.</p>",
  text: "Hello — thanks for signing up."
)

puts result["id"]

Sendara.new accepts the API key as the first argument plus optional keywords:

client = Sendara.new(
  ENV.fetch("SENDARA_API_KEY"),
  base_url: "https://api.sendara.dev",
  timeout: 30,
  max_retries: 2
)

The client automatically attaches an Idempotency-Key to every write and retries idempotent requests with exponential backoff (honoring Retry-After).

Sending email

emails.send is keyword-based. from, to, and subject are the essentials; provide html, text, or both:

client.emails.send(
  from: "you@yourdomain.com",
  to: "customer@example.com",
  subject: "Your receipt",
  html: "<p>Thanks for your order.</p>",
  text: "Thanks for your order.",
  message_type: "transactional",
  metadata: { "order_id" => "ord_123" }
)

Send with a stored template instead of inline content:

client.emails.send(
  from: "you@yourdomain.com",
  to: "customer@example.com",
  subject: "Your receipt",
  template_id: "tmpl_abc",
  template_vars: { "name" => "Ada", "total" => "$42.00" }
)

Pass your own idempotency_key to make retries safe across process restarts:

client.emails.send(
  from: "you@yourdomain.com",
  to: "customer@example.com",
  subject: "Your receipt",
  html: "<p>Thanks!</p>",
  idempotency_key: "receipt-ord_123"
)

For full control over the request envelope, use send_raw, or send_batch for many messages in one call:

client.emails.send_raw({
  "channel" => "email",
  "destination" => { "email" => "customer@example.com" },
  "payload" => { "subject" => "Hi", "body_html" => "<p>Hi</p>" }
})

client.emails.send_batch([request_a, request_b, request_c])

Broadcasts

Broadcasts send one email to an audience — a saved list or an inline set of recipients.

broadcast = client.broadcasts.create(
  from_email: "you@yourdomain.com",
  name: "June newsletter",
  subject: "What's new in June",
  body_html: "<h1>June</h1><p>Updates inside.</p>",
  body_text: "June updates inside.",
  audience_list_id: "list_123"
)

client.broadcasts.send(broadcast["id"])

Schedule for later, or send immediately on create with send_now: true:

client.broadcasts.create(
  from_email: "you@yourdomain.com",
  name: "Launch",
  subject: "We're live",
  body_html: "<p>We launched.</p>",
  recipients: ["a@example.com", "b@example.com"],
  scheduled_at: "2026-07-01T09:00:00Z"
)

List, fetch, cancel, and delete:

client.broadcasts.list(limit: 20, offset: 0)
client.broadcasts.get("bcast_123")
client.broadcasts.cancel("bcast_123")
client.broadcasts.delete("bcast_123")

To create-and-send an audience in a single request, use bulk_send (same arguments as create):

client.broadcasts.bulk_send(
  from_email: "you@yourdomain.com",
  subject: "Flash sale",
  body_html: "<p>24 hours only.</p>",
  audience_list_id: "list_123",
  send_now: true
)

Messages & pagination

Fetch a single page with messages.page, which returns a Sendara::MessagePage:

page = client.messages.page(status: "delivered", limit: 50)

page.messages.each { |message| puts message["id"] }
page.has_more?   # => true / false
page.next_cursor # => cursor string or nil

messages.each iterates across all pages transparently, following the cursor for you:

client.messages.each(status: "bounced") do |message|
  puts "#{message['id']}#{message['to']}"
end

Called without a block it returns an Enumerator, so the full Enumerable API is available:

recent = client.messages.each(channel: "email", limit: 100).first(10)

Filters: channel, status, from, to, limit, cursor. Fetch one message by id:

client.messages.get("msg_123")

Webhook verification

Verify the signature on incoming webhooks with Sendara::Webhooks.verify. Pass the raw request body (not parsed), the request headers, and your signing secret. On success it returns the parsed event payload; on failure it raises Sendara::WebhookVerificationError.

require "sendara"

event = Sendara::Webhooks.verify(
  raw_body,
  request.headers,
  ENV.fetch("SENDARA_WEBHOOK_SECRET")
)

event["type"] # => "email.delivered"

verify checks the Sendara-Signature HMAC and rejects requests whose Sendara-Timestamp is outside the tolerance window (default 300 seconds). Adjust it if needed:

Sendara::Webhooks.verify(raw_body, headers, secret, tolerance: 600)

Error handling

API responses outside the 2xx range raise Sendara::ApiError, which exposes the HTTP status, the machine-readable code, the request_id, and retry_after (seconds, when the server sent it):

begin
  client.emails.send(
    from: "you@yourdomain.com",
    to: "customer@example.com",
    subject: "Hi",
    html: "<p>Hi</p>"
  )
rescue Sendara::ApiError => e
  warn "#{e.code} (HTTP #{e.status}): #{e.message}"
  warn "request_id=#{e.request_id}"
  raise unless e.code == "rate_limited"
  sleep(e.retry_after || 1)
  retry
end

The exception hierarchy, all under Sendara::Error:

Class Raised when
Sendara::ApiError The API returned a non-2xx response. Has status, code, request_id, retry_after.
Sendara::ConnectionError The request could not reach the API (DNS, TLS, socket).
Sendara::TimeoutError The request exceeded the configured timeout. Subclass of ConnectionError.
Sendara::WebhookVerificationError A webhook signature, timestamp, or body failed verification.

Rescue Sendara::Error to catch everything from the gem:

rescue Sendara::Error => e
  # any Sendara failure
end

Rails usage

Build one client and share it. An initializer works well:

# config/initializers/sendara.rb
require "sendara"

SENDARA = Sendara.new(Rails.application.credentials.sendara_api_key)

Send from anywhere, ideally off the request cycle in a background job:

# app/jobs/welcome_email_job.rb
class WelcomeEmailJob < ApplicationJob
  queue_as :default

  def perform(user)
    SENDARA.emails.send(
      from: "hello@yourdomain.com",
      to: user.email,
      subject: "Welcome to Acme",
      html: WelcomeMailer.render(user),
      idempotency_key: "welcome-#{user.id}"
    )
  rescue Sendara::ApiError => e
    Rails.logger.error("sendara send failed: #{e.code} #{e.message} (#{e.request_id})")
    raise
  end
end

Receiving webhooks — verify against the raw body, which Rails exposes via request.raw_post:

# config/routes.rb
post "/webhooks/sendara", to: "sendara_webhooks#receive"

# app/controllers/sendara_webhooks_controller.rb
class SendaraWebhooksController < ActionController::API
  def receive
    event = Sendara::Webhooks.verify(
      request.raw_post,
      request.headers,
      Rails.application.credentials.sendara_webhook_secret
    )

    case event["type"]
    when "email.delivered" then handle_delivered(event)
    when "email.bounced"   then handle_bounced(event)
    end

    head :ok
  rescue Sendara::WebhookVerificationError
    head :bad_request
  end
end

License

MIT