FoPost Ruby SDK
The official Ruby SDK for the FoPost API. Connect social accounts once, then compose, schedule, and publish to +30 platforms from your own application.
Requires Ruby 3.1 or newer. No runtime dependencies: the gem talks over net/http from the standard library, so it drops into any app without a version conflict.
0.x release. The public API is still settling and minor versions may contain breaking changes. Pin an exact version if that matters to you.
Install
bundle add fopost
Or without Bundler:
gem install fopost
Get an API key
Create a key at app.fopost.com/api-keys. The full API reference lives at fopost.com/docs.
Quick start
require 'fopost'
client = Fopost.new(api_key: 'fp_...') # or set FOPOST_API_KEY
workspace = client.workspaces.list.first
accounts = client.accounts.list(workspace_id: workspace.id)
post = client.posts.create(
workspace_id: workspace.id,
content: 'Hello from Ruby',
accounts: accounts.map(&:id)
)
client.posts.publish(post.id)
content takes a string for a single block, or an array for a thread:
client.posts.create(
workspace_id: workspace.id,
content: [
'First post in the thread',
{
'text' => 'Second one, with an image',
'media' => [{ 'type' => 'image', 'name' => 'chart.png', 'url' => 'https://.../chart.png' }]
}
],
accounts: accounts.map(&:id)
)
accounts takes account ids, the Fopost::SocialAccount objects themselves, or hashes with an id.
Scheduling
status is "draft" or "scheduled"; a scheduled post needs schedule_at. To send something out now, create it and call publish.
client.posts.create(
workspace_id: workspace.id,
status: 'scheduled',
schedule_at: Time.utc(2026, 9, 1, 10, 0),
content: 'Scheduled with the SDK',
accounts: [accounts.first.id]
)
schedule_at accepts a Time, a DateTime, or an ISO 8601 string.
Posts
client.posts.get(post_id)
client.posts.update(post_id, title: 'Renamed') # partial: only what you pass is sent
client.posts.delete(post_id)
client.posts.publish(post_id) # queue delivery to every targeted account
client.posts.cancel(post_id) # cancel what has not gone out yet
client.posts.retry(post_id) # retry only the deliveries that failed
client.posts.preflight(post_id) # per-account blockers, without publishing
client.posts.deliveries(post_id).each { |d| puts "#{d.platform}: #{d.status}" }
update is a partial update, so passing nil clears a field and leaving an argument out leaves it alone:
client.posts.update(post_id, schedule_at: nil) # sends {"schedule_at": null}
client.posts.update(post_id, title: 'Renamed') # sends {"title": "Renamed"}
Pagination
posts.list returns one page, which is Enumerable over its items. posts.each walks every page for you.
page = client.posts.list(workspace_id: workspace.id, status: 'published', per_page: 50)
puts "#{page..total} published posts"
page.each { |post| puts "#{post.id} #{post.status}" }
# Every post, one page fetched at a time
client.posts.each(workspace_id: workspace.id) { |post| puts post.id }
# Or page by page, when you want the meta
client.posts.each_page(workspace_id: workspace.id) do |p|
puts "#{p..current_page} (#{p.size} posts)"
end
Both walkers return an Enumerator when called without a block, so client.posts.each(...).lazy.first(10) works.
AI features
balance = client.ai.credits
puts "#{balance.credits_remaining} of #{balance.credits_total} credits left"
result = client.ai.generate_caption(
current_caption: 'shipping a new feature',
platforms: %w[twitter linkedin]
)
puts result.caption
rewrite and repurpose_url are wired the same way:
rewrites = client.ai.rewrite(
content: 'Long article-style draft...',
platforms: %w[twitter linkedin bluesky]
)
rewrites.results.each { |variant| puts "#{variant.platform}: #{variant.content}" }
repurposed = client.ai.repurpose_url(
url: 'https://example.com/blog/post',
platforms: %w[twitter linkedin bluesky threads]
)
API keys reach
creditsandgenerate_caption.rewriteandrepurpose_urlcurrently require a signed-in dashboard session and answer401to an API key. They are here so the surface is complete once the server opens them up.
Errors
Every non-2xx response raises. All of them are rescuable as Fopost::Error.
| Status | Class |
|---|---|
| 400/422 | Fopost::ValidationError |
| 401 | Fopost::AuthenticationError |
| 402 | Fopost::PaymentRequiredError |
| 403 | Fopost::PermissionDeniedError |
| 404 | Fopost::NotFoundError |
| 429 | Fopost::RateLimitError |
| other | Fopost::Error |
begin
client.posts.publish(post_id)
rescue Fopost::PaymentRequiredError => e
warn "Out of credits — upgrade at #{e.upgrade_url}"
rescue Fopost::Error => e
warn "#{e.status} #{e.code}: #{e.}"
end
message is what the API said; to_s adds the status and code, so an uncaught error still reports both. e.body holds the decoded response, and Fopost::ValidationError#errors carries per-field messages when the API sends them.
Configuration
Fopost.new(
api_key: 'fp_...', # or FOPOST_API_KEY
base_url: 'https://api.fopost.com/v1', # override for a dev server
timeout: 30.0, # seconds
max_retries: 3, # total attempts on a 429
transport: MyTransport.new # bring your own HTTP stack
)
| Env var | Used for |
|---|---|
FOPOST_API_KEY |
API key, when not passed to the constructor |
A 429 is retried automatically, waiting for the interval the API asks for in Retry-After (delta-seconds or an HTTP date, capped at 60s). max_retries counts total attempts, so the default of 3 means two retries.
Endpoints the SDK does not wrap yet
client.request reaches anything in the API, with the same auth, retries, and error handling:
client.request(:get, '/analytics/overview', params: { 'workspace_id' => workspace.id })
client.request(:post, '/webhooks', json: { 'url' => 'https://example.com/hook', 'events' => ['post.published'] })
Swapping the HTTP stack
The default transport is net/http. Anything that responds to call(method:, url:, headers:, body:) and returns a Fopost::HTTP::Response can replace it — useful for a shared connection pool, custom instrumentation, or stubbing the network in tests.
class LoggingTransport
include Fopost::HTTP::Transport
def initialize(inner) = @inner = inner
def call(method:, url:, headers:, body:)
warn "#{method} #{url}"
@inner.call(method: method, url: url, headers: headers, body: body)
end
end
client = Fopost.new(transport: LoggingTransport.new(Fopost::HTTP::NetHTTPTransport.new))
Models
Responses come back as small model objects with snake_case readers. The API is not consistent about its wire casing — posts come back snake_case, accounts camelCase — so both spellings parse. Fields the SDK does not model yet stay reachable:
post.raw['someNewField'] # the decoded body, exactly as sent
post['some_new_field'] # by either spelling
That means a field added server-side never breaks an older client.
Development
bundle install
bundle exec rake test
bundle exec rubocop
Tests run against a stubbed transport, so nothing touches the network.
License
MIT. See LICENSE.