Misar.Blog Ruby SDK

The official Ruby client for the Misar.Blog developer API.

gem Ruby license

9 resource groups · 25 operations · standard library only (net/http)

Works with any Ruby 2.7+ program that needs to drive a Misar.Blog account: automating publishing, syncing a blog out of CI or another CMS, or building a reader, dashboard or integration on top of the API at https://api.misar.io/blog/v1.


Install

gem install misarblog

Or in a Gemfile:

gem "misarblog", "~> 1.1"

Ruby 2.7+.


Authentication

Mint a key at https://www.misar.blog/dashboard/settings/api. Keys are prefixed mbk_; an OAuth 2.1 access token works on the same header. Key management MisarBlog.new(api_key:, base_url:, timeout:, max_retries:) is a shorthand that delegates to MisarBlog::Client.new; the key is sent as Authorization: Bearer. See the first example below. The full request/response contract is published as an OpenAPI document at https://api.misar.io/blog/v1/openapi.json.


API surface

Resource Method Endpoint What it does
articles list GET /articles list your articles, filtered by status/visibility/sort
articles get GET /articles/{slug} fetch one article by slug or UUID, full Markdown body
articles publish POST /articles publish or schedule an article from Markdown
articles update PATCH /articles/{slug} update title/body/tags in place; publish: true flips a draft live
articles create_draft POST /drafts save a draft without publishing
articles search GET /search full-text search across articles, profiles and tags
articles recommendations GET /recommendations related articles for an article id
series list GET /series list your series
series create POST /series create a series
series add_article POST /series/{slug}/articles add an article to a series at a position
reactions get GET /reactions reaction counts and the caller's own reactions
reactions add POST /reactions add a like / clap / bookmark
reactions remove DELETE /reactions remove a reaction
comments list GET /comments an article's comment thread, newest first, replies one level deep
follows status GET /follows follower/following counts and whether the key's owner follows
ai complete POST /ai/complete free-form system + user completion
ai titles POST /ai/titles SEO/AEO/GEO title suggestions (seo from a keyword, suggest from copy)
images generate POST /images/generate AI cover image (1024x1024, 1792x1024, 1024x1792)
images upload POST /images/upload upload an image to the CDN
account profile GET /me the authenticated creator profile
account plan GET /plan live plan and per-feature quota
account trial_status GET /trial whether a self-serve trial is active
account start_trial POST /trial start a self-serve trial
account upsell_funnel GET /upsell-funnel per-feature upsell funnel (platform-admin keys only; a creator key gets 403)
analytics get GET /analytics views, gross/net revenue, active subscribers for trailing N days

Note the two groupings that differ from the other Misar.Blog SDKs: profile, plan, trial and upsell all hang off account, and the analytics summary is analytics.get rather than analytics.summary.


What's in the package

Item What it is
MisarBlog::Client The client. MisarBlog.new(api_key:, base_url:, timeout: 30, max_retries: 3) is the shorthand constructor. Resource readers: articles, series, reactions, comments, follows, ai, images, account, analytics.
Errors MisarBlog::ApiError, MisarBlog::PlanLimitError, MisarBlog::NetworkError.
MisarBlog.embed_url(username:, slug:, theme:) Pure string building for public embeds.
MisarBlog::Models Article, ArticleList, Series, SeriesList, Profile, Plan, PlanUsage, Analytics, ArticleReactions, ReactionResult, TrialStatus, TitlesResult, TitleSuggestion, AiText, ImageResult. Each wraps the decoded body: named readers for the documented fields, #[] for string-key access, and #raw / #to_h for the untouched Hash, so a field the API adds after this release is still reachable.
client.request(method, path, data) Public, so an endpoint this SDK does not wrap yet is still one call away.

Not everything is modelled. comments.list, follows.status, articles.search, articles.recommendations, account.start_trial and account.upsell_funnel return the plain decoded Hash with string keys.

Transport. Standard library only — net/http, uri, json; no runtime gem dependencies. Base URL https://api.misar.io/blog/v1; the key goes on Authorization: Bearer. Statuses 429/500/502/503/504 and connection failures are retried up to max_retries attempts (default 3) with exponential back-off from 300 ms; the final attempt is always surfaced. Open timeout 10 s, read timeout timeout: (default 30 s). A 204 or empty body returns {}.

No streaming or webhooks. Every operation is a single request/response. No SSE or WebSocket endpoint accepts an API key, and the API has no webhook registration route — webhook_only is an article visibility value, not a subscription.


Examples

Authenticate and publish

require "misarblog"

blog = MisarBlog.new(api_key: ENV.fetch("MISARBLOG_API_KEY"))

me = blog..profile
puts "authenticated as @#{me.username}"

article = blog.articles.publish(
  title: "Shipping a blog from CI",
  body_markdown: "# Shipping a blog from CI\n\nMarkdown in, article out.",
  tags: %w[ci automation]
)
puts article.url

Publish (or schedule) an article

article = blog.articles.publish(
  title: "Hello, Misar",
  body_markdown: "# Hello\n\nFirst post.",
  tags: ["intro"],
  cover_image_url: "https://cdn.example.com/cover.png",
  visibility: "public",                # public | subscribers | paid | private | webhook_only
  schedule_at: "2026-09-01T09:00:00Z"  # omit to publish immediately
)
puts "#{article.slug} #{article.status} #{article.url}"

title: and body_markdown: are required keywords; every other keyword is dropped from the request body when left nil.

Save a draft

draft = blog.articles.create_draft(
  title: "Work in progress",
  body_markdown: "Notes so far…",
  tags: ["draft"]
)
puts draft.editor_url # open in the Misar.Blog editor

List your articles

result = blog.articles.list(status: "published", limit: 20)
result.articles.each { |a| puts "#{a.slug} #{a["view_count"]}" }
puts "#{result.articles.size} of #{result.total}"

status: accepts draft, published, scheduled, archived or flagged; visibility:, webhook_only: and sort: narrow it further. Article exposes the common fields as readers and everything else through #[] or #raw.

Update an article — and publish a draft

updated = blog.articles.update(
  "work-in-progress",
  title: "Finished at last",
  body_markdown: "The complete post.",
  publish: true # flips a draft to published in the same call
)
puts "#{updated.status} #{updated.published_at}"

The slug is positional; everything else is a keyword, and omitted keywords are left out of the body so those fields stay unchanged.

Read an article's comment thread

thread = blog.comments.list(article_id: article.id, limit: 50, offset: 0)
thread["comments"].each do |c|
  puts "@#{c["user"]["username"]}: #{c["content"]} (#{c["reply_count"]} replies)"
end
puts "#{thread["totalCount"]} #{thread["hasMore"]}"

This one returns a plain Hash, not a model. Leave limit:/offset: out to take the server defaults of 20 (max 100) and 0.

Read and add reactions

counts = blog.reactions.get(article_id: article.id)
puts "#{counts.counts["clap"]} #{counts.total} #{counts.user_reactions}"

blog.reactions.add(article_id: article.id, type: "clap")    # like | clap | bookmark
blog.reactions.remove(article_id: article.id, type: "clap")

Generate SEO titles

result = blog.ai.titles(
  action: "seo", # "seo" from a keyword, "suggest" from existing copy
  prompt: "shipping a static blog from GitHub Actions"
)
result.titles.each { |t| puts "#{t.title}#{t.hint}" }

For "suggest", pass the article text as context: instead of prompt:.

Read the analytics summary

summary = blog.analytics.get(days: 30)
puts "#{summary.views} #{summary.revenue_cents} #{summary.active_subscribers}"

Generate a cover image

image = blog.images.generate(
  prompt: "a dark editorial illustration of a printing press",
  size: "1792x1024"
)
puts image.url

blog.images.upload(data) posts to the CDN upload route as JSON — pass the Hash the API expects (a base64 data field). This SDK does not build a multipart request for you; the Go and Python clients do.

Embed a public article

puts MisarBlog.embed_url(username: "gulshan", slug: "hello-misar", theme: "dark")
# https://misar.blog/gulshan/hello-misar/embed?theme=dark

Omit slug: to embed the whole profile; theme: defaults to "auto", which adds no query parameter.


Errors

Every failure raises. PlanLimitError and NetworkError both subclass ApiError, which subclasses StandardError — so a single rescue MisarBlog::ApiError catches everything from this SDK. Order narrowest-first.

Type Raised when Readers
ApiError Any non-2xx the SDK did not classify further — 400 bad payload, 401 missing/expired/revoked key, 403 the key lacks the route's scope, 404 unknown slug, plain 429 rate limit (100 req/min per key) after retries are exhausted, 5xx after retries status, error_type, body (the decoded error Hash)
PlanLimitError The subscription blocks the call: 429 + code: "plan_limit_exceeded" (a metered allowance is spent) or 402 (the feature is not on this plan). Never retried — retrying cannot help until the allowance resets or the plan changes plan, upgrade_url, retry_after, upgrade
NetworkError The request never reached the API — DNS, TLS, connection refused/reset, open or read timeout — on the final attempt cause_error; status is 0
begin
  blog.ai.complete(prompt: "Draft an intro paragraph")
rescue MisarBlog::PlanLimitError => e
  # Route the reader to checkout instead of reporting a bare failure.
  puts "#{e.plan} plan is out of credits — upgrade at #{e.upgrade_url}"
rescue MisarBlog::NetworkError => e
  puts "could not reach the API: #{e.cause_error}"
rescue MisarBlog::ApiError => e
  puts "#{e.status} #{e.body && e.body["required_scope"]}"
end

The 403 scope details are not promoted to named readers here — read required_scope and granted_scopes off e.body. If a failure lands in the wrong class, file it at https://github.com/Misar-AI/misarblog-sdks/issues.


MIT © Misar AI