OmniSocials Ruby SDK

The official Ruby client for the OmniSocials Public API. Schedule and publish posts, upload media, and read analytics across Instagram, Facebook, LinkedIn, YouTube, TikTok, X, Pinterest, Bluesky, Threads, Mastodon, and Google Business.

Full API reference: https://docs.omnisocials.com

Install

gem install omnisocials

Or in your Gemfile:

gem "omnisocials"

Requires Ruby 3.0+. Zero runtime dependencies: built entirely on the Ruby standard library (Net::HTTP, OpenSSL, JSON).

Quickstart

require "omnisocials"

client = OmniSocials::Client.new  # reads OMNISOCIALS_API_KEY from the environment
post = client.posts.create(content: "Hello from Ruby", channels: ["instagram", "linkedin"])
puts post["data"]["id"]

Authentication

Create an API key in the OmniSocials app under Settings -> API Keys. Keys look like omsk_live_... (or omsk_test_... for test keys) and are bound to one workspace.

Pass the key explicitly or set the OMNISOCIALS_API_KEY environment variable:

require "omnisocials"

client = OmniSocials::Client.new(api_key: "omsk_live_...")
# or: export OMNISOCIALS_API_KEY=omsk_live_... and just OmniSocials::Client.new

The client opens a fresh connection per request, so there is nothing to close. All responses are plain Hashes (parsed JSON, string keys), returned exactly as the API sends them.

Posts

Schedule a post

post = client.posts.create(
  content: "Big announcement coming Friday",
  channels: ["instagram", "facebook", "linkedin"],
  scheduled_at: "2026-08-01T09:00:00Z",
  media_urls: ["https://example.com/teaser.jpg"]
)

Omit scheduled_at to create a draft, or use create_and_publish to publish immediately:

post = client.posts.create_and_publish(
  content: "We are live!",
  channels: ["x", "bluesky"]
)

Post to specific platforms with platform options

content can be a per-platform Hash (with default as the fallback), and each platform has its own options Hash:

post = client.posts.create(
  content: {
    "default" => "New feature: best times to post",
    "linkedin" => "We just shipped best times to post. Here is how it works."
  },
  channels: ["linkedin", "instagram", "youtube"],
  media_urls: ["https://example.com/demo.mp4"],
  scheduled_at: "2026-08-01T09:00:00Z",
  instagram: { "share_to_feed" => true },
  youtube: { "title" => "Best times to post, explained", "privacy_status" => "public" }
)

Note that linkedin targets a personal LinkedIn profile and linkedin_page targets a LinkedIn company page. Both can be connected to the same workspace and posted to independently.

X thread example

Pass 2 to 25 thread_parts to publish a chained thread instead of a single tweet (each part is at most 280 characters). Bluesky and Mastodon support the same thread_parts shape:

post = client.posts.create(
  content: "How we cut our API response times by 80%",  # fallback text
  channels: ["x"],
  scheduled_at: "2026-08-01T15:00:00Z",
  x: {
    "thread_parts" => [
      { "text" => "How we cut our API response times by 80%. A thread:" },
      { "text" => "1/ We started by profiling every endpoint under real load." },
      { "text" => "2/ The biggest win: batching analytics reads into one query." },
      { "text" => "3/ Full write-up on the blog. Thanks for reading!" }
    ]
  }
)

On update, passing x: { "thread_parts" => nil } clears the thread and reverts the post to single-tweet mode. Only top-level nil values are dropped from request bodies, so nested nil values like this one are sent as JSON null.

Other post operations

posts = client.posts.list(status: "scheduled", limit: 50, offset: 0)
post = client.posts.get("123")
client.posts.update("123", scheduled_at: "2026-08-02T10:00:00Z")
client.posts.publish("123")           # publish a draft or scheduled post now
client.posts.delete("123")            # returns nil (204)

# Recent posts fetched live from the connected platforms (including content
# published outside OmniSocials). Requires the analytics:read scope.
recent = client.posts.recent_platform(limit: 10, platforms: ["instagram", "tiktok"])

Media

media = client.media.upload_from_url(
  url: "https://example.com/promo.mp4",
  name: "promo-august",
  folder: "Campaigns"
)
media_id = media["data"]["id"]

Files over 100MB are processed in the background: the response has media["data"]["status"] == "processing"; poll client.media.get(media_id) until it is "ready" before attaching it to a post.

Upload a local file (multipart, max 100MB)

media = client.media.upload(file: "/path/to/image.jpg", name: "hero-shot")

file accepts a file path (String or Pathname), an IO (File.open("...", "rb"), StringIO), or raw bytes as a binary-encoded String (e.g. from File.binread).

Uploading a PDF splits it into image slides (max 20 pages) and returns slides plus a media_ids array. Pass all of media_ids, in order, to posts.create to publish the deck as a carousel (a native swipeable document on LinkedIn, an image carousel elsewhere).

Every upload response also includes a compatibility block listing any connected platforms that would reject the file.

Other media operations

files = client.media.list(search: "promo", folder_id: "42")
item = client.media.get("1001")
client.media.update("1001", name: "renamed", folder_id: "42")
client.media.update("1001", folder_id: nil)   # move to the root ("All media")
client.media.delete("1001")

# Base64 upload (handy when you have bytes but no URL)
client.media.upload_from_base64(data: b64_string, mime_type: "image/png")

# Preflight compatibility BEFORE uploading
result = client.media.check(url: "https://example.com/huge.mp4")
puts result["summary"] unless result["compatible"]

Note the difference between omitting folder_id (leave the folder unchanged) and passing folder_id: nil (move the file to the root). The same applies to parent_id on folders.update.

Large file uploads with a presigned URL

create_upload_url mints a one-time upload URL so any environment (a CI job, a sandbox, a script without the SDK) can upload a file without auth headers:

grant = client.media.create_upload_url
# grant contains: upload_url, upload_token, expires_in_seconds (600),
# method ("POST"), content_type ("multipart/form-data")

POST the file to grant["upload_url"] as multipart form data (field name file) with no auth headers. The token is single-use and expires after 10 minutes. The response JSON contains the created media item with its id (or a media_ids array for a PDF).

Folders

folders = client.folders.list
folder = client.folders.create(name: "Campaigns")
sub = client.folders.create(name: "August", parent_id: folder["data"]["id"])
client.folders.update(sub["data"]["id"], name: "August 2026")
client.folders.update(sub["data"]["id"], parent_id: nil)  # move to top level
client.folders.delete(folder["data"]["id"])  # files move to root, subfolders move up

Accounts

accounts = client.accounts.list
accounts["data"].each do ||
  puts "#{["platform"]}: #{["username"]}"
end

 = client.accounts.get("instagram")

Analytics

# One post
stats = client.analytics.post("123")

# Up to 100 posts in one request (avoids rate limit trouble when syncing)
bulk = client.analytics.posts(["123", "124", "125"])

# Workspace overview
overview = client.analytics.overview(period: "30d")

# Account-level stats (followers etc.)
accounts = client.analytics.accounts(platform: "instagram")

Best times to post

best = client.analytics.best_times(platform: "instagram", timezone: "Europe/Amsterdam")
best["data"]["best_times"].each { |slot| puts slot }

Locations (Instagram place tagging)

results = client.locations.search("Blue Bottle Coffee Oakland")
location_id = results["data"][0]["id"]
client.locations.validate(location_id)

client.posts.create(
  content: "Great coffee here",
  channels: ["instagram"],
  media_urls: ["https://example.com/latte.jpg"],
  location_id: location_id
)

Webhooks

Subscribe to post.scheduled, post.published, and post.failed events:

webhook = client.webhooks.create(
  url: "https://example.com/hooks/omnisocials",
  events: ["post.published", "post.failed"]
)
secret = webhook["data"]["secret"]  # only shown once, store it

client.webhooks.list
client.webhooks.get(webhook["data"]["id"])
client.webhooks.update(webhook["data"]["id"], is_active: false)
client.webhooks.rotate_secret(webhook["data"]["id"])  # new secret, shown once
client.webhooks.delete(webhook["data"]["id"])

Verifying webhook signatures (Rails example)

Every delivery is signed with HMAC-SHA256. The X-OmniSocials-Signature header has the form t=<unix>,v1=<hex>, signed over "{timestamp}.{raw_body}". Use OmniSocials::Webhooks.verify with the raw request body (do not parse it first):

class OmnisocialsWebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    payload = request.body.read  # raw body, exactly as received
    signature = request.headers["X-OmniSocials-Signature"]

    begin
      event = OmniSocials::Webhooks.verify(
        payload: payload,
        signature: signature,
        secret: ENV.fetch("OMNISOCIALS_WEBHOOK_SECRET"),
        tolerance: 300
      )
    rescue OmniSocials::WebhookVerificationError
      return head :bad_request
    end

    if event["type"] == "post.published"
      event["data"]["targets"].each do |target|
        Rails.logger.info "#{target["platform"]} #{target["status"]} #{target["native_post_id"]}"
      end
    end

    head :ok
  end
end

Verifying webhook signatures (plain Rack)

# config.ru
require "omnisocials"

app = lambda do |env|
  request = Rack::Request.new(env)
  payload = request.body.read
  signature = env["HTTP_X_OMNISOCIALS_SIGNATURE"]

  begin
    event = OmniSocials::Webhooks.verify(
      payload: payload,
      signature: signature,
      secret: ENV.fetch("OMNISOCIALS_WEBHOOK_SECRET")
    )
  rescue OmniSocials::WebhookVerificationError
    next [400, { "content-type" => "application/json" }, ['{"error":"invalid signature"}']]
  end

  # handle event["type"] ...
  [200, { "content-type" => "application/json" }, ['{"ok":true}']]
end

run app

OmniSocials::Webhooks.verify uses a constant-time comparison, rejects timestamps older than tolerance seconds (default 300) to block replays, and returns the parsed event Hash on success. Respond with any 2xx within 10 seconds to acknowledge; deliveries are retried up to 3 times.

Error handling

Non-2xx responses raise typed exceptions with status, code, message, and the parsed body:

begin
  client.posts.get("does-not-exist")
rescue OmniSocials::NotFoundError => e
  puts "Gone: #{e.code} #{e.message}"
rescue OmniSocials::RateLimitError => e
  puts "Slow down, retry in #{e.retry_after}s"
rescue OmniSocials::ValidationError => e
  puts "Bad request: #{e.body}"
rescue OmniSocials::AuthenticationError
  puts "Check your API key"
rescue OmniSocials::APIConnectionError
  puts "Network problem"
rescue OmniSocials::APIError => e
  puts "API error: #{e.status} #{e.message}"
end

The full hierarchy: OmniSocials::Error is the base; OmniSocials::APIError covers all HTTP errors with subclasses AuthenticationError (401), PermissionDeniedError (403), NotFoundError (404), ValidationError (400/422), RateLimitError (429, with #retry_after), and ServerError (5xx); APIConnectionError covers network failures; WebhookVerificationError covers signature failures.

Rate limits

The API allows 100 requests per minute per API key. Every response includes X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. On 429 the SDK automatically waits for the Retry-After value and retries. When syncing analytics for many posts, prefer the bulk client.analytics.posts([...]) endpoint (100 posts per request) over per-post calls.

Retries and timeouts

The SDK automatically retries on 429, 5xx, and connection errors with exponential backoff (0.5s, 1s, 2s, with jitter), honoring the Retry-After header when present. Other 4xx errors are never retried. Defaults: 2 retries, 30 second timeout. Both are configurable:

client = OmniSocials::Client.new(
  api_key: "omsk_live_...",
  timeout: 60,        # seconds
  max_retries: 5,     # retries after the first attempt
  base_url: "https://api.omnisocials.com/v1"  # override for testing
)

More

  • Full API reference and guides: https://docs.omnisocials.com
  • MCP server for AI agents: npx -y @omnisocials/mcp-server
  • Agent skills CLI: npx skills add OmniSocials/omnisocials-agent-skills

License

MIT