# OnlyFans Ruby SDK

> A thread-safe Ruby client for the OnlyFans API. Call any endpoint from a script, a Rails app or a background job, with automatic retries and RBS and Sorbet types.

- Requires Ruby 3.2+ (on Ruby 3.4+, also add the base64 gem)
- Install: `bundle add onlyfans base64`, require: `require "onlyfans"`, client: `Onlyfans::Client.new`
- Source: https://github.com/onlyfansapi/onlyfansapi-ruby
- Package: `onlyfans` on RubyGems: https://rubygems.org/gems/onlyfans (Apache 2.0)

## Make your first OnlyFans call in Ruby

Add the gem, set your API key, and list your connected accounts.

The OnlyFans Ruby SDK is on RubyGems as onlyfans and needs Ruby 3.2 or newer. It uses net/http from the standard library, plus connection_pool. On Ruby 3.4 and later, also add base64: the gem loads it, but Ruby no longer ships it by default. Ruby 3.4 prints a warning without it, and Ruby 4.0 fails to load the gem.

```bash
bundle add onlyfans base64
# or in your Gemfile:
# gem "onlyfans"
# gem "base64"
```

Create an API key in the OnlyFansAPI dashboard and store it in the ONLYFANSAPI_API_KEY environment variable: export it in your shell, or keep it in .env and load it with the dotenv gem. In Rails, use credentials (below). Onlyfans::Client.new reads the variable automatically, so the key stays out of your code and your Git history. You can also pass it with Onlyfans::Client.new(api_key: ...).

```bash
export ONLYFANSAPI_API_KEY=your_api_key

# Plain Ruby does not read .env files. To keep the key in .env,
# add the dotenv gem and put require "dotenv/load" first.
```

List every OnlyFans account connected to your key. Each account has an acct_ ID, and you pass that ID to every other call: chats, fans, messages, media and statistics.

```ruby
require "onlyfans"

# Reads ONLYFANSAPI_API_KEY from the environment
client = Onlyfans::Client.new

# GET /api/accounts: every OnlyFans account connected to your API key
accounts = client.accounts.list

accounts.each do |account|
  puts "#{account.id} · @#{account.onlyfans_username}"
end

# Output:
# acct_XXXXXXXXXXXXX · @sarah_k
```

## OnlyFans Ruby SDK code examples

Working calls for the jobs agencies automate first: tracking-link revenue, chats, messages, paid messages and media.

Read clicks, subscribers, spenders and lifetime revenue for a tracking link, and work out revenue per subscriber. Response fields can be nil, so the example uses &. and || 0.

```ruby
require "onlyfans"

client = Onlyfans::Client.new

# GET /api/{account}/tracking-links/{tracking_link_id}/stats
stats = client.tracking_links.get_stats("123456", account: "acct_XXXXXXXXXXXXX")

summary = stats.data&.summary
if summary
  subs = summary.subs_total || 0
  revenue = summary.revenue_total || 0.0
  arps = subs.zero? ? 0.0 : revenue / subs

  puts "Clicks: #{summary.clicks_total} · Subs: #{subs}"
  puts "Spenders: #{summary.spenders_total}"
  puts format("ARPS: $%.2f · Lifetime: $%.2f", arps, revenue)
end

# Output:
# Clicks: 1240 · Subs: 300
# Spenders: 42
# ARPS: $12.40 · Lifetime: $3720.00
```

List the fans who paid after arriving through a tracking link, with what each one spent. min_spend: hides anyone below your threshold.

```ruby
require "onlyfans"

client = Onlyfans::Client.new

# GET /api/{account}/tracking-links/{tracking_link_id}/spenders
spenders = client.tracking_links.list_spenders(
  "123456",
  account: "acct_XXXXXXXXXXXXX",
  min_spend: 10,
  limit: 50
)

(spenders.data || []).each do |fan|
  total = fan.revenue&.total || 0.0
  puts format("@%s spent $%.2f", fan.username, total)
end

# Output:
# @sarah_k spent $189.00
```

Load unread chats for an account, with the fan, the unread count and the last message in each.

```ruby
require "onlyfans"

client = Onlyfans::Client.new

# GET /api/{account}/chats
chats = client.chats.list("acct_XXXXXXXXXXXXX", filter: :unread, limit: "20")

(chats.data || []).each do |chat|
  puts "@#{chat.fan&.username} · unread: #{chat.unread_messages_count}"
  puts "  #{chat.last_message&.text}"
end

# Output:
# @sarah_k · unread: 3
#   Hey, are you around?
```

Read the newest messages in one chat, newest first.

```ruby
require "onlyfans"

client = Onlyfans::Client.new

# GET /api/{account}/chats/{chat_id}/messages
messages = client.chats.messages.list(
  "987654321",
  account: "acct_XXXXXXXXXXXXX",
  limit: "20",
  order: "desc"
)

(messages.data || []).each do |message|
  puts "[#{message.created_at}] #{message.text}"
end

# Output:
# [2026-09-04T18:22:41+00:00] Just posted something new for you 💜
```

Send a text with a media attachment to a fan's chat. The method is send_, with an underscore, because every Ruby object already has a send method.

```ruby
require "onlyfans"

client = Onlyfans::Client.new

# POST /api/{account}/chats/{chat_id}/messages
# The method is send_ with an underscore, because Ruby's Object#send is taken.
sent = client.chats.messages.send_(
  "987654321",
  account: "acct_XXXXXXXXXXXXX",
  text: "Hey! Thanks for subscribing 💜",
  media_files: ["ofapi_media_XXXXXXXXXXXXX"]
)

puts "Message sent ##{sent.data.id}" if sent.data

# Output:
# Message sent #6182934517
```

Upload a photo from disk with Pathname, then send it as a paid message for $10. Passing a Pathname streams the file without reading it into memory first. The upload returns an ofapi_media_ ID that works once, in one post or one message. Prices are whole US dollars: 0 for free, or 3 to 200.

```ruby
require "onlyfans"
require "pathname"

client = Onlyfans::Client.new
account = "acct_XXXXXXXXXXXXX"

# POST /api/{account}/media/upload
# The returned ID can be attached to one post or message.
upload = client.media.upload(account, file: Pathname("preview.jpg"))

# POST /api/{account}/chats/{chat_id}/messages
sent = client.chats.messages.send_(
  "987654321",
  account: account,
  text: "Something new, just for you 💜",
  media_files: [upload.prefixed_id],
  price: 10 # USD, 0 or 3–200
)

puts "PPV sent ##{sent.data.id}" if sent.data
```

Download a file from an OnlyFans CDN URL through the API's proxy. download returns the whole file as a binary String, so write it with File.binwrite. The file is held in memory, which is fine for photos and short clips.

```ruby
require "onlyfans"

client = Onlyfans::Client.new

cdn_url = "https://cdn2.onlyfans.com/files/…/photo.jpg?Expires=…"

# GET /api/{account}/media/download/{cdn_url}
# Returns the file as a binary String.
data = client.media.download(cdn_url, account: "acct_XXXXXXXXXXXXX")

File.binwrite("photo.jpg", data)
```

## Features

- **Thread-safe client**: Create one client and share it across Puma threads and job workers. Each client keeps a pool of up to 99 connections, so requests skip a new TLS handshake.
- **RBS and Sorbet types**: Yard docs, RBS signatures and RBI files ship with the gem, so Steep, Sorbet and your editor check and autocomplete calls without sorbet-runtime.
- **Retries and timeouts**: Connection errors, timeouts, 408, 409, 429 and 5xx responses are retried twice with backoff. Every request has a 60-second timeout you can change.

## Use the OnlyFans Ruby SDK with Rails

Store the key in encrypted credentials, share one client across the app, and process webhooks with Active Job.

Run bin/rails credentials:edit and add your API key and webhook signing secret under onlyfansapi. Rails encrypts the file, so you can commit it, and production reads it with RAILS_MASTER_KEY.

```yaml
# bin/rails credentials:edit
onlyfansapi:
  api_key: ofapi_XXXXXXXXXXXXX
  webhook_secret: your_webhook_signing_secret
```

Build the client once in config/initializers/onlyfans.rb. Bundler loads the gem, so there is no require. Controllers and jobs share this one thread-safe client and its connection pool.

```ruby
# config/initializers/onlyfans.rb
# One client for the whole app. It is thread-safe and keeps a connection
# pool, so controllers and jobs all share it.
ONLYFANS = Onlyfans::Client.new(
  api_key: Rails.application.credentials.dig(:onlyfansapi, :api_key)
)
```

A controller action that returns an account's latest fans as JSON. A wrong or disconnected account ID raises NotFoundError, which becomes a 404. The route is in the comment at the top.

```ruby
# app/controllers/fans_controller.rb
# Route: get "accounts/:account_id/fans/latest", to: "fans#latest"
class FansController < ApplicationController
  def latest
    page = ONLYFANS.fans.list_latest(params[:account_id], limit: 20)
    fans = (page.data&.users || []).map { |fan| fan.to_h.slice(:id, :username, :name) }

    render json: fans
  rescue Onlyfans::Errors::NotFoundError
    head :not_found
  end
end
```

OnlyFansAPI signs each webhook with an HMAC SHA256 of the raw body, sent in the Signature header. request.raw_post is that raw body; compare signatures with ActiveSupport::SecurityUtils.secure_compare. Rails.cache.write with unless_exist: true only succeeds the first time it sees an X-OFAPI-Idempotency-Key, so retried deliveries are not queued twice. Deliveries time out after 10 seconds, so queue the work and return 204. If queueing fails, the key is deleted so OnlyFansAPI's retry gets through. The controller inherits from ActionController::API, so there is no CSRF check to skip.

```ruby
# app/controllers/onlyfans_webhooks_controller.rb
# Route: post "webhooks/onlyfans", to: "onlyfans_webhooks#create"
class OnlyfansWebhooksController < ActionController::API
  def create
    # raw_post is the exact body OnlyFansAPI signed
    secret = Rails.application.credentials.dig(:onlyfansapi, :webhook_secret)
    expected = OpenSSL::HMAC.hexdigest("SHA256", secret, request.raw_post)
    signature = request.headers["Signature"].to_s
    return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(expected, signature)

    # Retries reuse the same key, so each event is queued once.
    key = request.headers["X-OFAPI-Idempotency-Key"].presence
    cache_key = "ofapi-webhook:#{key}"
    if key && !Rails.cache.write(cache_key, true, unless_exist: true, expires_in: 1.day)
      return head :no_content
    end

    queued = false
    begin
      queued = OnlyfansEventJob.perform_later(JSON.parse(request.raw_post))
    ensure
      # Queueing failed: forget the key so the retry is not skipped.
      Rails.cache.delete(cache_key) if key && !queued
    end

    head(queued ? :no_content : :service_unavailable)
  end
end
```

An Active Job that welcomes new subscribers. The chat ID is the fan's user ID, so payload["fan"]["id"] is all you need to message them. retry_on waits longer after each rate-limit error. In production, Rails 8 runs it on Solid Queue with bin/jobs. Sidekiq and GoodJob work without code changes.

```ruby
# app/jobs/onlyfans_event_job.rb
class OnlyfansEventJob < ApplicationJob
  queue_as :default
  retry_on Onlyfans::Errors::RateLimitError, wait: :polynomially_longer, attempts: 5

  def perform(event)
    return unless event["event"] == "subscriptions.new"

    # The chat ID is the fan's user ID
    fan = event.dig("payload", "fan")
    ONLYFANS.chats.messages.send_(
      fan["id"].to_s,
      account: event["account_id"],
      text: "Welcome, #{fan["name"]}! Thanks for subscribing 💜"
    )
  end
end
```

## Call the OnlyFans API from many threads

One client can serve many threads at once, which helps when an agency manages dozens of accounts.

Count unread chats on every connected account, five accounts at a time, with plain Ruby threads that share one client. The chats endpoint has no has_more flag, so each thread keeps loading pages until _pagination.next_page is empty. In Puma cluster mode, build the client before workers fork (an initializer does this) or after; just don't fork while a request is in flight.

```ruby
require "onlyfans"

# One client is thread-safe and keeps its own connection pool,
# so every thread can share it.
client = Onlyfans::Client.new

accounts = client.accounts.list.map(&:id)

# Count unread chats on every account, five accounts at a time
unread = accounts.each_slice(5).flat_map do |batch|
  threads = batch.map do |account|
    Thread.new do
      count = 0
      offset = 0
      loop do
        page = client.chats.list(account, filter: :unread, limit: "100", offset: offset.to_s)
        count += (page.data || []).size
        # Chats have no has_more: stop when next_page is empty
        break if page._pagination&.next_page.nil?

        offset += 100
      end
      [account, count]
    end
  end
  threads.map(&:value)
end

unread.each { |account, count| puts "#{account}: #{count} unread chats" }
```

## Load every fan, chat or post page by page

List endpoints return one page at a time. Loop until has_more is false and move the offset by the full page size each time.

OnlyFans filters some entries on its side, so a page can hold 19 fans when you asked for 20 and still not be the last one. Don't stop on a short page. has_more is the reliable end signal, and moving the offset by the full limit skips nobody.

```ruby
require "onlyfans"

client = Onlyfans::Client.new

account = "acct_XXXXXXXXXXXXX"
limit = 20
offset = 0
usernames = []

loop do
  # GET /api/{account}/fans/all
  page = client.fans.list_all(account, limit: limit, offset: offset)
  data = page.data
  break unless data

  usernames.concat((data.list || []).filter_map(&:username))

  # A page can hold fewer than limit fans and still not be the last one.
  # Trust has_more, and move the offset by the full limit.
  break unless data.has_more

  offset += limit
end

puts "#{usernames.size} fans loaded"
```

Write every active fan to a CSV with Ruby's CSV library, for a CRM import or a spreadsheet. The loop follows has_more, so large accounts export completely. On Ruby 3.4 and later, csv is a separate gem: add it to your Gemfile.

```ruby
require "csv"
require "onlyfans"

client = Onlyfans::Client.new

CSV.open("fans.csv", "w") do |csv|
  csv << %w[id username name]

  offset = 0
  loop do
    # GET /api/{account}/fans/active
    page = client.fans.list_active("acct_XXXXXXXXXXXXX", limit: 20, offset: offset)
    (page.data&.list || []).each do |fan|
      csv << [fan.id, fan.username, fan.name]
    end

    break unless page.data&.has_more

    offset += 20
  end
end
```

## Handle errors, retries and timeouts

Every failed request raises a typed error, and short outages are retried before your code sees them.

Each HTTP status has its own error class under Onlyfans::Errors, and all of them inherit from APIError. Rescue the ones you can act on, then APIStatusError for the rest; it carries status, headers and body. APITimeoutError is a subclass of APIConnectionError, so rescue it first.

```ruby
require "onlyfans"

client = Onlyfans::Client.new

begin
  chats = client.chats.list("acct_XXXXXXXXXXXXX", limit: "10")
  puts "Chats loaded: #{(chats.data || []).size}"
rescue Onlyfans::Errors::AuthenticationError
  # 401: the API key is missing, revoked or mistyped
rescue Onlyfans::Errors::NotFoundError
  # 404: wrong account ID, or the account was disconnected
rescue Onlyfans::Errors::RateLimitError
  # 429: still limited after the SDK's own retries, so back off
rescue Onlyfans::Errors::APITimeoutError
  # no answer within the timeout, even after retries
rescue Onlyfans::Errors::APIConnectionError => e
  warn e.cause.inspect # DNS, TLS or network failure from net/http
rescue Onlyfans::Errors::APIStatusError => e
  warn "#{e.status}: #{e.body}" # any other 4xx or 5xx
end
```

The SDK retries connection errors, timeouts, and 408, 409, 429 and 5xx responses twice. It waits 0.5 seconds before the first retry, doubles the wait each time up to 8 seconds, and follows Retry-After. Raise max_retries: for background jobs, or pass request_options: {max_retries: 0} while a user waits.

```ruby
require "onlyfans"

# Connection errors, timeouts, 408, 409, 429 and 5xx responses are retried
# twice by default, with exponential backoff that respects Retry-After.
client = Onlyfans::Client.new(max_retries: 4)

# Or switch retries off for one call, e.g. while a user waits on the page
chats = client.chats.list(
  "acct_XXXXXXXXXXXXX",
  limit: "10",
  request_options: {max_retries: 0}
)
puts "Chats loaded: #{(chats.data || []).size}"
```

Requests time out after 60 seconds. Pass timeout: in seconds to the client, or in request_options for one call. A timed-out request is retried, then raises APITimeoutError. Time spent waiting for a free pooled connection counts toward the timeout.

```ruby
require "onlyfans"

# The default is 60 seconds per request
client = Onlyfans::Client.new(timeout: 30)

# Shorter timeout for one call; on expiry the SDK retries, then raises APITimeoutError
chats = client.chats.list(
  "acct_XXXXXXXXXXXXX",
  limit: "10",
  request_options: {timeout: 10}
)
puts "Chats loaded: #{(chats.data || []).size}"
```

New endpoints can ship before the SDK has a method for them. client.request calls any path with the same authentication, retries and timeouts, and extra_query, extra_body and extra_headers add fields to a typed call.

```ruby
require "onlyfans"

client = Onlyfans::Client.new

# Call an endpoint the SDK has no method for yet.
# Authentication, retries and timeouts still apply.
response = client.request(
  method: :get,
  path: "api/acct_XXXXXXXXXXXXX/some-new-endpoint",
  query: {limit: 10}
)
puts response[:data]

# Or add a parameter the SDK does not know about to a typed call
chats = client.chats.list(
  "acct_XXXXXXXXXXXXX",
  request_options: {extra_query: {new_filter: "value"}}
)
puts "Chats loaded: #{(chats.data || []).size}"
```

## Track credits and rate limits from Ruby

Every response reports what the call cost and how much room you have left, so you can slow down before you hit a limit.

Read _meta on any response. Inside are _credits, _rate_limits and _cache, named as in the JSON: credits used and left, requests left this minute and today, and whether the answer came from cache. Cached responses cost 0 credits but still count toward your rate limit.

```ruby
require "onlyfans"

client = Onlyfans::Client.new

fans = client.fans.list_all("acct_XXXXXXXXXXXXX", limit: 20)

# Every response carries _meta with credits, rate limits and cache state.
meta = fans._meta
if meta
  puts "Credits used: #{meta._credits&.used}"
  puts "Credits left: #{meta._credits&.balance}"
  puts "Requests left this minute: #{meta._rate_limits&.remaining_minute}"
  puts "From cache: #{meta._cache&.is_cached ? "yes" : "no"}"
end

# Output:
# Credits used: 1
# Credits left: 9876
# Requests left this minute: 998
# From cache: no
```

## What Ruby teams build with the OnlyFans API

- **Agency admin panels**: Show earnings, tracking-link revenue and fan lists inside a Rails admin built with Avo, ActiveAdmin or your own views.
- **Scheduled syncs**: Copy fans, transactions and statistics into Postgres on a schedule, with Solid Queue recurring tasks, sidekiq-cron or a rake task.
- **AI chat assistants**: Read chat history, draft replies with an LLM through ruby_llm or the OpenAI gem, and send them back with send_.
- **Multi-account services**: Run one process across every creator account on your key, each addressed by its acct_ ID, with one shared client.
- **Media workflows**: Upload files from disk or Active Storage, attach them to posts and paid messages, and download originals for backup.
- **Webhook automations**: Welcome new subscribers, tag big spenders or alert your team in Slack when a webhook arrives, using Active Job.

## FAQ

### How do I install the OnlyFans Ruby SDK?

Run bundle add onlyfans, or add gem "onlyfans" to your Gemfile, then require "onlyfans" (Rails loads it for you). On Ruby 3.4 and later, also add the base64 gem, which the SDK loads but Ruby no longer bundles.

### Which Ruby versions are supported?

Ruby 3.2 and newer. We tested the examples on this page on Ruby 3.2, 3.4 and 4.0, and the Rails examples on Rails 8.1.

### Does it work with Rails?

Yes. Store the key in encrypted credentials, create the client in an initializer, and call it from controllers and Active Job. The Rails section above shows each file, including a signed webhook controller.

### Is the client thread-safe?

Yes. One Onlyfans::Client can be shared by Puma threads, Sidekiq workers and plain Ruby threads. Each client has its own connection pool, so create it once per process instead of once per request.

### How do I verify webhook signatures in Ruby?

Compute OpenSSL::HMAC.hexdigest("SHA256", secret, request.raw_post) and compare it with the Signature header using ActiveSupport::SecurityUtils.secure_compare (or Rack::Utils.secure_compare outside Rails). Hash the raw body, not JSON you parsed and encoded again, or the signature won't match.

### What happens when a request fails?

The SDK raises a subclass of Onlyfans::Errors::APIError, such as AuthenticationError, NotFoundError, RateLimitError or APITimeoutError. Connection errors, timeouts, and 408, 409, 429 and 5xx responses are first retried twice with exponential backoff.

### Why is the send method called send_?

Every Ruby object already has send, which calls a method by name. The SDK adds an underscore so it doesn't override it: client.chats.messages.send_(chat_id, account: ..., text: ...).

### Can I call an endpoint that is not in the SDK yet?

Yes. client.request(method: :get, path: "...") calls any path with the same authentication and retries, and the extra_query, extra_body and extra_headers request options add fields to a typed call.

### Is the OnlyFans Ruby SDK free?

Yes. The gem is open source under the Apache 2.0 license and free to install. The API calls it makes use credits from your OnlyFansAPI plan.

## Links

- Get your API key: https://app.onlyfansapi.com/register
- Ruby SDK docs: https://github.com/onlyfansapi/onlyfansapi-ruby/blob/main/README.md
- Source on GitHub: https://github.com/onlyfansapi/onlyfansapi-ruby
- API reference: https://docs.onlyfansapi.com/introduction
- Webhook signatures: https://docs.onlyfansapi.com/webhooks/protecting-your-webhooks
- All SDKs: https://onlyfansapi.com/sdk