OnlyFans API with Ruby
Most OnlyFans API Ruby code starts as a one-file script and ends up inside Rails, so build it the way it will eventually live: one small client object, a Faraday middleware stack that handles backoff, and sends dispatched from a background job. ofapis is an unofficial, independent service, not run by or affiliated with OnlyFans. Accounts are linked once in the dashboard and the session refreshed server-side, so the only secret your Ruby process holds is an ofapis_sk_... key — create one on sign-up, details in authentication.
Gems
# Gemfile
source "https://rubygems.org"
gem "faraday", "~> 2.9"
gem "faraday-retry", "~> 2.2"
gem "faraday-net_http_persistent" # keeps one pooled TLS connection
group :test do
gem "rspec"
gem "webmock"
gem "vcr"
end
bundle install, then export the key. Never ENV["OFAPIS_KEY"] — ENV.fetch("OFAPIS_KEY") fails loudly at boot instead of sending Bearer with nothing after it and puzzling over a 401 UNAUTHENTICATED at 3am.
One client object
Faraday's real value is the middleware stack: retry, JSON encoding and JSON parsing are declared once at build time and every method inherits them. Build the connection in the constructor, not per call.
# lib/ofapis/client.rb
require "faraday"
require "faraday/retry"
require "faraday/net_http_persistent"
module Ofapis
BASE = "https://api.ofapis.com/api/public/v1"
class Error < StandardError
attr_reader :status, :code
def initialize(status:, code:, message:)
@status = status
@code = code
super("#{status} #{code}: #{message}")
end
end
class Client
RETRY = {
max: 4,
interval: 0.5,
backoff_factor: 2,
retry_statuses: [429, 500, 502, 503, 504],
# faraday-retry reads Retry-After off the 429 itself and waits that long
# instead of using the backoff curve.
retry_if: ->(env, _exc) { env.status == 429 }
}.freeze
def initialize(token: ENV.fetch("OFAPIS_KEY"), account_id: nil)
@account_id = account_id
@conn = Faraday.new(url: BASE) do |f|
f.request :json # Hash -> JSON body
f.request :retry, RETRY
f.response :json # JSON -> Hash with STRING keys
f.adapter :net_http_persistent
f.headers["Authorization"] = "Bearer #{token}"
end
end
def me = get("/me").fetch("data")
# Chats are keyed by withUser.id, not by a separate chat id.
def each_chat(limit: 50)
return enum_for(:each_chat, limit: limit) unless block_given?
offset = 0
loop do
page = get("/chats", limit: limit, offset: offset)
page.dig("data", "list").to_a.each { |chat| yield chat }
break unless page.dig("data", "hasMore")
offset = page.dig("data", "nextOffset")
end
end
def send_message(chat_id:, text:, price_cents: 0, locked_text: false, media_files: [])
raise ArgumentError, "price_cents must be an Integer" unless price_cents.is_a?(Integer)
post("/chats/#{chat_id}/messages",
text: text, price: price_cents,
lockedText: locked_text, mediaFiles: media_files).fetch("data")
end
private
# Agencies run several creators under one key: pass account_id: and every
# path is mirrored under /accounts/{id}.
def path(suffix) = @account_id ? "/accounts/#{@account_id}#{suffix}" : suffix
def get(suffix, **params) = unwrap(@conn.get(path(suffix), params))
def post(suffix, **body) = unwrap(@conn.post(path(suffix), body))
def unwrap(res)
return res.body if res.success?
err = res.body.is_a?(Hash) ? res.body["error"] : nil
raise Error.new(status: res.status,
code: err&.dig("code") || "UNKNOWN",
message: err&.dig("message") || res.reason_phrase.to_s)
end
end
end
Note what is deliberately missing: f.response :raise_error. It discards the body, and the body is where {"error":{"code","message"}} lives — 402 INSUFFICIENT_CREDITS and 424 OF_SESSION_EXPIRED need different handling and only the code tells them apart (error reference). Hash#dig does the rest, since every success is wrapped in {"data": ...} and lists nest again under list.
client = Ofapis::Client.new
puts "linked: #{client.me["username"]}"
client.each_chat.first(5).each do |chat|
puts "#{chat.dig("withUser", "name")} — #{chat["unreadMessagesCount"]} unread"
end
Send from a job, not a request
A send is a round trip to a third party; it does not belong in a Puma worker holding a browser connection open. Enqueue it.
# app/jobs/send_dm_job.rb
class SendDmJob < ApplicationJob
queue_as :dms
retry_on Ofapis::Error, wait: :polynomially_longer, attempts: 5
def perform(chat_id:, text:, dedupe_key:)
claim = OutboundDm.find_or_create_by!(dedupe_key: dedupe_key) # unique index
return if claim.message_id.present? # a previous attempt already delivered
message = Ofapis::Client.new.send_message(chat_id: chat_id, text: text)
claim.update!(message_id: message.fetch("id"))
rescue Ofapis::Error => e
raise unless %w[INSUFFICIENT_CREDITS ACCOUNT_NOT_LINKED].include?(e.code)
claim.update!(failed_reason: e.code) # retrying these never succeeds
end
end
ActiveJob and Sidekiq both retry on any raised exception, and a retry after a timeout cannot distinguish a lost response from a lost request. The unique dedupe_key plus the message_id check is what stops a fan getting the same DM twice. Bulk sends matter most here: a mailing is charged per recipient rather than per call. Use the mass DM endpoint rather than looping SendDmJob over a subscriber list.
Two things that bite in Ruby
price is cents, as an Integer. price: 4.99 is a Float and wrong by two orders of magnitude. Convert at the boundary — Money.from_amount(4.99, "USD").cents or (BigDecimal("4.99") * 100).to_i — and keep cents all the way down. The guard clause in send_message exists because this is the most common bug in Ruby integrations.
Pick string keys or symbol keys and stay there. Faraday's :json response middleware parses with string keys; JSON.parse(body, symbolize_names: true) gives symbols. Mixing them means chat[:withUser] quietly returns nil on a string-keyed hash, and that nil only surfaces three frames later. If your app is symbol-first, symbolize once inside unwrap.
Specs that do not spend credits
Metering is one credit per successful call — a failed one is free — so a spec suite that reaches the network quietly bills you for every green 2xx. Block it at the socket: require "webmock/rspec" fails any unstubbed connection.
# spec/ofapis/client_spec.rb
require "webmock/rspec"
RSpec.describe Ofapis::Client do
let(:base) { "https://api.ofapis.com/api/public/v1" }
let(:client) { described_class.new(token: "test_key") }
it "surfaces the error code from the envelope" do
stub_request(:post, "#{base}/chats/77/messages")
.with(headers: { "Authorization" => "Bearer test_key" })
.to_return(status: 402,
headers: { "Content-Type" => "application/json" },
body: { error: { code: "INSUFFICIENT_CREDITS",
message: "top up" } }.to_json)
expect { client.send_message(chat_id: 77, text: "hi") }
.to raise_error(Ofapis::Error) { |e| expect(e.code).to eq("INSUFFICIENT_CREDITS") }
end
it "replays a 429 once Retry-After elapses" do
stub_request(:get, "#{base}/me").to_return(
{ status: 429, headers: { "Retry-After" => "0" } },
{ status: 200,
headers: { "Content-Type" => "application/json" },
body: { data: { username: "demo", isAuth: true } }.to_json }
)
expect(client.me["username"]).to eq("demo")
expect(a_request(:get, "#{base}/me")).to have_been_made.twice
end
end
The second spec is the one worth keeping: it proves the retry middleware is wired up without waiting out a real rate limit. VCR works the same way if you prefer recorded fixtures — record once against a throwaway key, set record: :none in CI, and add config.filter_sensitive_data("<TOKEN>") { ENV["OFAPIS_KEY"] } so no cassette carries your key into git.
FAQ
Which Faraday adapter should I use for the OnlyFans API?
net_http_persistent. The default net_http adapter opens a fresh TLS connection per request, which on a per-minute budget is pure latency. net-http-persistent keeps a per-thread pool, so one Ofapis::Client shared across Puma threads and Sidekiq workers is safe and reuses sockets. The httpx and typhoeus adapters work too.
How do I stop my specs from calling the live ofapis API?
Add require "webmock/rspec" to spec/spec_helper.rb. It disables real connections globally and raises WebMock::NetConnectNotAllowedError naming the unstubbed URL. Successful test-suite calls are billed like any other, and a runaway loop in a spec drains credits fast.
Why is my message price rejected or wildly wrong?
price is an integer count of cents, so send 499, not 4.99. Validate the type at your client boundary rather than trusting callers — the API cannot tell a deliberate 499 cents from a mistyped one.
Does faraday-retry handle 429 automatically?
Yes, once 429 is listed in retry_statuses. It reads Retry-After off the response and sleeps that long instead of applying its own backoff curve. Watch X-RateLimit-Remaining on successful responses to throttle before you hit the ceiling, which varies by plan — see rate limits.
Should a Rails controller call the API directly?
No. Wrap it in an ActiveJob or Sidekiq worker so a slow upstream cannot pin a web thread, and make the job idempotent with a unique dedupe key. Retries are a certainty, and a resent paid DM is a customer-facing bug.