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: [], idempotency_key: nil)
raise ArgumentError, "price_cents must be an Integer" unless price_cents.is_a?(Integer)
post("/chats/#{chat_id}/messages", idempotency_key,
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))
# Every POST accepts Idempotency-Key, and faraday-retry will resend this
# body on a 429, so pass one for anything that spends money or writes.
def post(suffix, idempotency_key = nil, **body)
unwrap(@conn.post(path(suffix), body) { |req|
req.headers["Idempotency-Key"] = idempotency_key if idempotency_key
})
end
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
each_chat's limit: 50 is inside the server's accepted range; limit defaults to 20 and is clamped to 1–100, so raising it past 100 changes nothing while making the loop look like it should terminate sooner. Saved fan lists under /accounts/{id}/lists default to 10 instead, and message history ignores offset entirely — it walks backwards with olderThanMessageId, so an each_message enumerator needs a different loop body from this one.
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 — almost everything worth automating on a creator account ends up as a queue of writes like this one.
# 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, idempotency_key: dedupe_key
)
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 is doing double duty: it is the local claim row, and it is the Idempotency-Key the server deduplicates on. The local check stops a second HTTP call being made at all; the header stops the one that does get made from writing twice, returning the first response with Idempotency-Replayed: true and no second charge. A duplicate that arrives while the first is still in flight gets 409 IDEMPOTENCY_CONFLICT — debited then refunded — so handle it by reading back, not by sending again.
Bulk sends matter most here. A mailing is charged per recipient rather than per call: the trigger is weight 0 and the run debits one credit per recipient as it dispatches, throttled to one message every 500 ms. Use the mass DM endpoint rather than looping SendDmJob over a subscriber list — and note that a subscriber record is only {id, username, name, avatar, subscribedOn}, so any spend-based targeting has to come from your own ledger, not from the subscribers endpoint.
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
Credits are debited before the request reaches a handler, so a spec suite that touches the network bills you for the red examples as well as the green ones. 400, 404, 413, 415 and 422 are charged and not refunded; only 401, 402, 403 and 429 are refused before metering, and only 409, 424 and 5xx are debited and then given back. A fixture with a typo'd path is a 404 and costs exactly as much as the call it was standing in for. Nor are the weights flat: the default is 1, but a media upload is 5 and a scheduled post or message is 3, so an integration suite that exercises those paths for real is not a rounding error. 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
it "does not retry a 400, which is debited on every attempt" do
stub_request(:get, "#{base}/me").to_return(
status: 400,
headers: { "Content-Type" => "application/json" },
body: { error: { code: "BAD_REQUEST", message: "limit out of range" } }.to_json
)
expect { client.me }.to raise_error(Ofapis::Error)
expect(a_request(:get, "#{base}/me")).to have_been_made.once
end
it "sends the Idempotency-Key through on a POST" do
stub_request(:post, "#{base}/chats/77/messages")
.with(headers: { "Idempotency-Key" => "welcome-77" })
.to_return(status: 200,
headers: { "Content-Type" => "application/json" },
body: { data: { id: 9001 } }.to_json)
client.send_message(chat_id: 77, text: "hi", idempotency_key: "welcome-77")
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. Test-suite calls are billed like any other, and failures count too — a spec that posts a malformed body gets a 400 and is charged for it — so 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.