MisarMail Ruby SDK

Send transactional email and run marketing campaigns from Ruby — one client, no runtime dependencies.

gem ruby license

33 resource groups · 90 methods · SSE streaming · webhook signature verification

MisarMail is one API for both halves of your email: the receipts and password resets your product sends, and the campaigns, segments and automations your marketing team runs on the same contact list and the same verified domains.

Built on net/http with nothing outside the standard library, for Ruby 2.7+. Every method returns a plain Hash with string keys, so nothing breaks when the API adds a field.


Install

RubyGems

gem install misarmail

Bundler

gem "misarmail", "~> 1.0"

Authentication

Create a developer key at https://mail.misar.io/developers. It starts with msk_ and is sent as Authorization: Bearer msk_….

Every call is metered against the subscription attached to that key. There is no client-side limit checking — the server decides, and the SDK surfaces its answer. A plan refusal answers 403 with code: "plan_limit_exceeded" and is never retried.

require "misar_mail"

mail = MisarMail::Client.new(api_key: ENV.fetch("MISARMAIL_API_KEY"))

Resources

Every group the client exposes, and every public method on it.

Send

Resource Methods What it covers
mail.email send Transactional send — cc/bcc/reply-to, tags, metadata, idempotency_key.
mail.sandbox send, list, delete Test sends captured instead of delivered.

Campaigns and tests

Resource Methods What it covers
mail.campaigns list, create, get, update, send, delete Marketing campaigns: draft, edit, queue for send.
mail.ab_tests list, create, get, set_winner Subject, content, send-time, from-name and preheader splits, and winner selection.

Audience

Resource Methods What it covers
mail.contacts list, create, get, update, delete, import_contacts Subscribers, plus bulk import.
mail.segments members Dynamic audience segments and their membership.
mail.landing_pages create Hosted landing pages with an email capture form.

Content

Resource Methods What it covers
mail.templates list, create, get, update, delete, render Reusable templates and server-side variable rendering.
mail.ai subject_lines AI-generated subject lines.

Automations

Resource Methods What it covers
mail.automations list, create, get, update, delete, activate Trigger-based workflows — welcome series, drips, re-engagement.

Deliverability and sending infrastructure

Resource Methods What it covers
mail.domains list, create, get, verify, delete Sending domains and their DNS verification.
mail.dmarc check, list_domains, add_domain, remove_domain Live SPF/DKIM/DMARC record checks and monitored domains.
mail.deliverability audit, score Deliverability score, audit and remediation guidance.
mail.dedicated_ips list, create, update, delete Dedicated sending IPs.
mail.warmup get IP/domain warm-up progress and today's remaining capacity.
mail.inbound list, create, get, delete Inbound routing domains, so replies land in the unified inbox.

Mailbox and inbox

Resource Methods What it covers
mail.emails list, get, update Stored messages in the mailbox.
mail.email_accounts list Connected mailbox accounts.

Analytics and attribution

Resource Methods What it covers
mail.analytics overview Delivery and engagement stats — aggregate, or one campaign.
mail.track event, purchase Custom events and ecommerce purchases.
mail.revenue attribution Revenue attributed back to email.
mail.usage get Metered usage for a period.

Validation

Resource Methods What it covers
mail.validate email Address validation, and the credit balance behind it.

Plan, billing and credits

Resource Methods What it covers
mail.plan get, monetization Current plan, quotas and monetization stats.
mail.billing subscription, checkout Subscription state and checkout.
mail.subscription get, upsert, cancel Subscription read/write and per-product plan limits.
mail.wallet get, credit, debit Credit balance, credit and debit.
mail.credit_rates list What each metered action costs in credits.
mail.team_members get Team members on the account.
mail.monetization tip Newsletter tips.

Developer

Resource Methods What it covers
mail.keys list, create, get, revoke API keys — create, list, revoke.
mail.webhooks list, create, get, update, delete, test Webhook endpoints, plus a test delivery.
mail.streaming generate_email, campaign_send The two Server-Sent Events endpoints.

Client

Thing Detail
Entry point MisarMail::Client.new(api_key:, timeout: 30, max_retries: 3, base_url:). MisarMail.new(**kwargs) is a shorthand.
base_url https://api.misar.io/mail/v1
Results A Hash with string keys. 204/empty comes back as {}; a top-level array is wrapped as {"data" => [...]}.
Transport net/http, 10-second open timeout, your timeout for reads.
Retried 429, 500, 502, 503, 504, plus Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET and SocketError — 300 ms then 600 ms.
Never retried Plan refusals, and streams.
Errors MisarMail::ApiError, with NetworkError and PlanLimitError extending it.
Webhook verifier MisarMail::Webhooks.verify / .sign.
Escape hatch mail.request(:get, path) is public, for routes with no method yet.

Quick start

require "misar_mail"

mail = MisarMail::Client.new(api_key: ENV.fetch("MISARMAIL_API_KEY"))

sent = mail.email.send(
  from: { email: "you@yourdomain.com", name: "Your App" },
  to: [{ email: "someone@example.com" }],
  subject: "Hello",
  html: "<p>Hi there</p>"
)

puts sent["message_id"]

Primary functions

Send a transactional email

from is a single address hash and to is an array of them. Pass an idempotency_key and a retry can never send twice — the response comes back with idempotent: true the second time.

res = mail.email.send(
  from: { email: "receipts@yourdomain.com", name: "Acme" },
  to: [{ email: "customer@example.com" }],
  reply_to: { email: "support@yourdomain.com" },
  subject: "Your receipt",
  html: "<p>Thanks for your order.</p>",
  text: "Thanks for your order.",
  tags: ["receipt"],
  metadata: { order_id: "ord-1041" },
  idempotency_key: "ord-1041-receipt"
)

res["message_id"]  # "msg-…"

List and create contacts

Responses are enveloped. list returns {"success", "data", "pagination"} and takes page:/limit: keywords rather than a params hash; create returns {"success", "data"} with the contact under data.

page = mail.contacts.list(page: 1, limit: 50)
puts "#{page['data'].length} of #{page['pagination']['total']}"

created = mail.contacts.create(
  email: "new@example.com",
  firstName: "Ada",
  lastName: "Lovelace",
  tags: ["beta"],
  customFields: { plan: "pro" }
)

puts created["data"]["id"]

get and delete take the contact id, which the route reads from the query string rather than a path segment. update is different again: it identifies the contact by email address, not by id.

mail.contacts.update("ada@example.com", status: "unsubscribed")

Bulk import contacts

The method is import_contactsimport is not defined. Counts come back under summary, and errors is a separate list of messages.

imported = mail.contacts.import_contacts(
  contacts: [
    { email: "a@example.com", firstName: "A" },
    { email: "b@example.com", firstName: "B" }
  ],
  updateExisting: true
)

imported["summary"]  # {"imported"=>…, "updated"=>…, "skipped"=>…, "errors"=>…}
imported["errors"]   # array of strings

Create and send a campaign

Campaigns take fromName and fromEmail as separate fields — there is no from hash here, unlike email.send. campaigns.send(id) queues the campaign and reports it as scheduled.

campaign = mail.campaigns.create(
  name: "March launch",
  subject: "We just shipped",
  fromName: "Ada at Acme",
  fromEmail: "hello@yourdomain.com",
  replyTo: "support@yourdomain.com",
  bodyHtml: "<h1>It's live</h1>",
  segmentId: "seg-123"
)

queued = mail.campaigns.send(campaign["data"]["id"])
puts "#{queued['campaignId']} #{queued['status']}"  # … scheduled

Only draft, scheduled or paused campaigns can be updated, and only draft campaigns can be deleted.

Validate an address

Each call spends a credit, and the response tells you what is left.

check = mail.validate.email("someone@example.com")

check["data"]["is_valid"]             # true / false
check["data"]["score"]                # 0–1 confidence
check["data"]["checks"]               # {"syntax"=>…, "mx"=>…, "smtp"=>…}
check["data"]["flags"]["disposable"]
check["credits"]["balance_after"]     # credits remaining

Render a template

rendered = mail.templates.render(
  template_id: "tpl-123",
  variables: { name: "Ada", plan: "Pro" }
)

rendered["data"]["subject"]  # "Welcome, Ada"
rendered["data"]["html"]

Track events and revenue

The event name field is event_name, and purchase totals are integer cents in total_cents.

mail.track.event(
  email: "customer@example.com",
  event_name: "viewed_pricing",
  event_data: { plan: "pro" }
)

purchase = mail.track.purchase(
  email: "customer@example.com",
  order_id: "ord-1041",
  total_cents: 9900,
  currency: "USD",
  items: [{ name: "Pro annual", quantity: 1, price_cents: 9900 }]
)

purchase["attribution"]  # which campaign or automation earned it

Read analytics and manage keys

Without campaignId you get aggregate usage and totals for the period; with one you get that campaign's stats and rates. keys.list returns the keys under keys, not data, and create returns the raw key exactly once.

overall = mail.analytics.overview(startDate: "2026-04-01", endDate: "2026-04-30")
one = mail.analytics.overview(campaignId: campaign_id)

keys = mail.keys.list
keys["keys"].length

fresh = mail.keys.create(name: "CI", scopes: %w[send read])
puts fresh["key"]  # shown once and never again

Verify an inbound webhook

MisarMail signs each delivery as HMAC-SHA256(timestamp + "." + raw_body), sending the digest in X-Misar-Signature and the Unix timestamp in X-Misar-Timestamp. Verify against the raw request body — re-serializing the parsed hash changes key order and whitespace, and so changes the digest. verify compares in constant time, rejects timestamps older than 300 seconds by default, and returns false rather than raising on malformed input.

ok = MisarMail::Webhooks.verify(
  payload: request.raw_post,
  signature: request.headers["X-Misar-Signature"],
  timestamp: request.headers["X-Misar-Timestamp"],
  secret: ENV.fetch("MISARMAIL_WEBHOOK_SECRET"),
  tolerance: 300
)
head :bad_request unless ok

MisarMail::Webhooks.sign(payload, timestamp, secret) produces the same digest, which is what you want when testing your own consumer.

Errors

Three classes, all under MisarMail:

Class When
ApiError Any non-2xx API response. Carries status and error_type.
NetworkError The request never got an answer, or every retry was spent. status is 0.
PlanLimitError The subscription behind the key does not cover the call.

NetworkError and PlanLimitError both subclass ApiError, so rescue MisarMail::ApiError catches everything the SDK raises.

Plan limits

Both a spent allowance and a feature that is not on the plan answer 403, carrying code: "plan_limit_exceeded". The SDK keys on that code rather than the status, which is why a refusal is typed correctly even though 403 is otherwise an authorization failure. It raises PlanLimitError and does not retry — retrying cannot help until the allowance resets or the plan changes. Read upgrade_url to send the user somewhere useful.

begin
  mail.campaigns.create(
    name: "Blast", subject: "We just shipped",
    fromName: "Your Name", fromEmail: "you@yourdomain.com"
  )
rescue MisarMail::PlanLimitError => e
  warn "#{e.feature} exhausted on #{e.plan}: #{e.upgrade_url}"
  # e.retry_after is seconds until the allowance resets, when the API says so
end

mail.plan.get returns plan, sending (the per-day and per-month email caps), usage — an array with one entry per metered feature, each carrying used, limit and remaining — and upgrade, which is null until a quota is tight. A null limit means unlimited, and remaining is null alongside it rather than 0. Read it before an expensive call rather than discovering the ceiling through a refusal.

The key needs the read or subscription scope.

plan = mail.plan.get
p plan["sending"]
p plan["usage"]

Streaming

Two endpoints stream Server-Sent Events. Both sit outside /v1, which the SDK handles for you:

Method Route
streaming.generate_email POST /api/ai/generate-email/stream
streaming.campaign_send GET /api/campaigns/{id}/send-stream

Frames are unnamed (data: {…}, with no event: line) and the stream ends with data: [DONE], which the SDK consumes rather than handing on. Each StreamEvent carries event (normally nil), data (the parsed hash, or nil when the payload was not JSON) and raw. Without a block you get an Enumerator instead. A stream is never retried: replaying one that failed mid-flight would duplicate whatever you had already read.

Note that streaming always talks to https://api.misar.io/mail — unlike the other methods it does not follow the base_url: you passed the client.

mail.streaming.generate_email(prompt: "a launch email") do |event|
  print event.data["delta"]
end

mail.streaming.campaign_send(campaign_id) do |event|
  puts event.raw
end

MIT © Misar AI