# Ruby Social Media API SDK

The official Ruby SDK. Ruby 3.0+, zero runtime dependencies (Net::HTTP, OpenSSL,
JSON), plain-Hash responses.

## Install

```bash
gem install omnisocials
```

Or in your Gemfile:

```ruby
gem "omnisocials"
```

## Authentication

```ruby
require "omnisocials"

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

## Create a post

```ruby
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"]
)
```

Any `media_urls` (or `media_ids`) entry can also be a Hash carrying per-media
alt text (max 1500 chars), delivered to Mastodon, Bluesky, X (photos/GIFs),
Pinterest, Instagram (images), and LinkedIn (images):

```ruby
media_urls: [{ "url" => "https://example.com/teaser.jpg", "alt" => "Red sneaker on a white background" }]
```

Publish immediately:

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

## Upload media

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

Local files: `client.media.upload(file: "/path/to/image.jpg", name: "hero-shot")`
(`file` accepts a path String/Pathname, an IO, or a raw binary String). Base64:
`client.media.upload_from_base64(data: b64_string, mime_type: "image/png")`.

## Analytics

```ruby
# 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")
```

## Social Inbox

Read and answer DMs, comments and mentions from your connected accounts. The API key
needs the opt-in `inbox:read` / `inbox:write` scopes. The lists use cursor pagination
(`cursor:` in, `pagination["next_cursor"]` out).

```ruby
conversations = client.inbox.list_conversations(platform: "instagram", unread: true)
conversation_id = conversations["data"][0]["conversation_id"]

messages = client.inbox.get_messages(conversation_id)
client.inbox.reply(conversation_id, text: "Thanks for reaching out!")
client.inbox.mark_read(conversation_id)

# Comment moderation: hide (hide: false unhides) or delete
client.inbox.hide(messages["data"][0]["id"])
client.inbox.delete_message(messages["data"][0]["id"])
```

`inbox.next` hands out the next conversation that still needs an answer, with the
thread and the post it belongs to. On a comment thread, pass `message_id:` so the reply
goes under the comment you answer and not under the newest comment on the post.

```ruby
item = client.inbox.next
while item["data"]
  message = item["data"]["message"]

  unless item["data"]["reply_window"]["open"]
    # An Instagram/Facebook DM past Meta's 24-hour window: answer it in the app
    client.inbox.mark_read(message["conversation_id"])
    item = client.inbox.next
    next
  end

  reply = client.inbox.reply(
    message["conversation_id"],
    text: "Thanks! DM sent.",
    message_id: message["id"],
    include_next: true
  )
  item = { "data" => reply["next"], "remaining" => reply["remaining"] || 0 }
end
```

See the [Social Inbox guide](/inbox) for the platforms, the reply rules and the error
codes.

## Pinterest product tags

Tag products on a Pin so people can shop the items in the image. List the product Pins
of the connected Pinterest account, then pass up to 24 of their Pin ids as
`"product_tags"` in the `pinterest:` options of the post. Only product Pins of your own
account can be tagged; products of other merchants cannot.

```ruby
result = client.pinterest.list_products

if result["error"]
  warn "#{result["error"]["code"]}: #{result["error"]["message"]}"
else
  client.posts.create(
    content: "Our summer picks",
    channels: ["pinterest"],
    media_urls: ["https://example.com/summer-look.jpg"],
    scheduled_at: "2026-08-01T09:00:00Z",
    pinterest: {
      "board_id" => "1234567890",
      "product_tags" => result["products"].first(3).map { |product| product["pin_id"] }
    }
  )
end
```

The list answers with HTTP 200 in both cases: `"products"` on success, or an `"error"`
hash (`pinterest_not_connected`, `pinterest_catalog_access_required`, `platform_error`)
when it could not be read. It reads the Pinterest catalog when the connection has
catalog access (given one time in the OmniSocials composer: Pinterest options, Add
products, Connect catalog), else the account's own Pins. The tags are added right after
the Pin is published. A product that Pinterest refuses never fails the post: the post
then carries `pinterest["product_tags_result"]` with the tagged and the skipped Pin ids.
`client.pinterest.validate_product(id)` checks one Pin id or Pin link before you post.
On `posts.update` the `pinterest:` hash replaces the stored one, so leave
`"product_tags"` out to remove the tags.

## Verify webhooks

Rails controller:

```ruby
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
    elsif event["type"] == "post.rejected"
      Rails.logger.info "Rejected: #{event["data"]["post_id"]} #{event["data"]["approval"]["reason"]}"
    end

    head :ok
  end
end
```

Events: `post.scheduled`, `post.published`, `post.failed`, `post.approved` and `post.rejected`.
The two approval events carry `data.approval` with `status`, `decided_by` (the approver's
user id) and `reason` (`nil` on `post.approved`). To read the full review of a post (steps,
approvers, the rejection reason and the comment thread), call `client.posts.get_approval(post_id)`.

## Errors

The base class is `OmniSocials::Error`. `OmniSocials::APIError` covers HTTP errors
with subclasses `AuthenticationError` (401), `PermissionDeniedError` (403),
`NotFoundError` (404), `ValidationError` (400/422), `RateLimitError` (429, with
`#retry_after`), and `ServerError` (5xx), plus `APIConnectionError` and
`WebhookVerificationError`. Each carries `status`, `code`, `message`, and `body`.

```ruby
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
```

## Configuration

```ruby
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
)
```

## Notes

- A fresh connection is used per request; there is nothing to close
- All responses are plain Hashes with string keys
- Omitting `folder_id`/`parent_id` leaves a value unchanged; passing `nil` explicitly moves to root/top level
- Source: [github.com/OmniSocials/omnisocials-ruby](https://github.com/OmniSocials/omnisocials-ruby)
