OnlyFans API in Python: a nightly subscriber sync
The Python job below runs once a night: pull the active-subscriber roster into a local table, work out who is new since yesterday, send each of them one automated welcome DM. It is short enough to read in a sitting and complete enough that nothing important is left as an exercise — retries, paging, idempotency and the credit arithmetic are all here. Everything targets https://api.ofapis.com/api/public/v1 with a single Bearer token; ofapis is an independent service holding the OnlyFans session server-side, so this process never sees a creator password (authentication, start free).
A Session with the retry policy mounted on it
pip install requests
export OFAPIS_KEY="ofapis_sk_..."
requests is enough; httpx works identically if you want async. The part worth copying is the adapter, because retry policy belongs on the transport and not scattered through the call sites:
import os
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
BASE = "https://api.ofapis.com/api/public/v1"
retry = Retry(
total=4,
backoff_factor=0.5, # 0.5s, 1s, 2s, 4s
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset({"GET", "POST"}), # POST is NOT in the default set
respect_retry_after_header=True, # a 429 carries Retry-After, in seconds
raise_on_status=False, # keep the error body readable
)
session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {os.environ['OFAPIS_KEY']}",
"Accept": "application/json",
})
session.mount("https://", HTTPAdapter(max_retries=retry, pool_maxsize=16))
Three of those arguments are load-bearing. allowed_methods defaults to the idempotent verbs only, so a POST is never retried unless you say so — adding it here is safe only because every POST in this script carries an Idempotency-Key. respect_retry_after_header is what makes a 429 cost you exactly the wait the server asked for instead of a guessed exponential curve. And raise_on_status=False stops urllib3 raising MaxRetryError, which requests re-raises as RetryError with the JSON body thrown away — the body is the only place the error code lives.
Dataclasses over the envelope
Every success is {"data": ...} and every list page is {"list": [...], "hasMore": bool, "nextOffset": int} inside it. Pinning that in one place beats body["data"]["list"][0]["username"] in nine:
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Subscriber:
id: int
username: str
name: str
avatar: str | None
subscribed_on: bool
@classmethod
def parse(cls, d: dict) -> "Subscriber":
return cls(
id=d["id"],
username=d["username"],
name=d.get("name") or d["username"],
avatar=d.get("avatar"),
subscribed_on=d.get("subscribedOn", False),
)
class OfapisError(RuntimeError):
def __init__(self, status, code, message, request_id):
super().__init__(f"{status} {code}: {message}")
self.status, self.code, self.request_id = status, code, request_id
def unwrap(r: requests.Response):
body = r.json()
if r.ok:
return body["data"]
err = body.get("error", {})
raise OfapisError(r.status_code, err.get("code", "UNKNOWN"),
err.get("message", ""), r.headers.get("X-Request-Id"))
A subscriber row carries id, username, name, avatar and the subscribedOn flag — there is no lifetime-spend field on it, so revenue has to come from your own records (subscribers endpoint), and growth, churn and net adds from diffing the snapshots this job already writes. Keep X-Request-Id off every failure; it is echoed back from your request and is the fastest thing to quote in a support thread.
Paging as a generator
from typing import Iterator
def subscribers(kind: str = "active") -> Iterator[Subscriber]:
offset = 0
while True:
r = session.get(
f"{BASE}/subscribers",
params={"type": kind, "limit": 100, "offset": offset},
timeout=(5, 30), # (connect, read) — requests has no default
)
page = unwrap(r)
yield from (Subscriber.parse(row) for row in page["list"])
if not page["hasMore"]:
return
offset = page["nextOffset"]
limit defaults to 20 and the server clamps it into 1–100, so a hopeful limit=500 silently returns 100 rows and any offset you compute yourself drifts by 400 — read nextOffset back instead. The missing timeout is the other classic: requests waits forever by default, and a cron job that hangs until the next cron job starts is worse than one that fails.
The write, keyed so a crash cannot double-send
chatId is the fan's own OnlyFans user id, so a subscriber row already contains the conversation key; no lookup call is needed between the read and the write.
from datetime import date
def welcome(fan: Subscriber, key: str) -> dict:
r = session.post(
f"{BASE}/chats/{fan.id}/messages",
json={"text": f"Hey {fan.name}, thanks for subscribing.", "price": 0},
headers={"Idempotency-Key": key},
timeout=(5, 30),
)
if r.headers.get("Idempotency-Replayed") == "true":
print(f"{key} already delivered on an earlier attempt")
return unwrap(r)
run = date.today().isoformat()
for fan in subscribers():
if fan.id in already_greeted: # your table, not the API's
continue
welcome(fan, key=f"welcome:{fan.id}:{run}")
Any POST accepts Idempotency-Key. The record is scoped to the token and the key, lives for 24 hours, and stores the original status and body: a replay inside that window returns the first response with Idempotency-Replayed: true and is not metered twice, while a second request racing the first gets 409 IDEMPOTENCY_CONFLICT. That 24-hour window is exactly why fan.id plus the run date makes a good key — re-running tonight's job after a crash is free and silent, and tomorrow's run starts clean. Note also that the record is matched on the key alone, not on the body, so recycling a key with different text replays the old message rather than sending the new one. price is an integer of cents and 0 means free; the messaging endpoint covers locked PPV sends.
Branch on the code, not the status
try:
sync()
except OfapisError as e:
if e.code == "INSUFFICIENT_CREDITS": # 402 — refused before metering, free
alert("top up before the next run")
elif e.code in ("OF_SESSION_EXPIRED", "ACCOUNT_NOT_LINKED", "OF_AUTH_INVALID"):
alert("a human must re-link the account in the dashboard")
elif e.code == "RATE_LIMIT_EXCEEDED": # the adapter already slept and still lost
reschedule(minutes=5)
else:
raise
The status tells you how to retry; the error.code string tells you whether retrying is pointless. A session that needs re-linking will answer identically for the next hour no matter how patient your backoff is, so it needs a person, not a sleep. Full list in the error reference.
What a run costs
Reads are one credit, and so is an unscoped write each here, and the ceiling is per token per minute: 60 on Free, 100 on Starter, 500 on Pro (rate limits). A roster of 4,000 active subscribers is 40 pages, so 40 credits, and 40 requests is under even the Free ceiling if the run is sequential. The welcomes are what scale: 200 new subscribers is 200 more credits and 200 more requests, which at 60 rpm is three and a half minutes of wall clock. Read X-RateLimit-Remaining off each response and sleep until X-RateLimit-Reset when it approaches zero rather than discovering the limit through 429s. Above a few hundred recipients, stop looping sends entirely and hand the list to the mass DM endpoint, which is priced per recipient and throttles server-side.
FAQ
Should I use requests or httpx to call the OnlyFans API from Python?
Either. requests with a mounted HTTPAdapter is the shortest path and is what this page uses; httpx is worth it when the surrounding job is already async, since httpx.AsyncClient gives you concurrency without threads. The envelope handling, the idempotency header and the paging loop are identical in both — only the transport line changes.
Why does my urllib3 Retry never retry a POST?
Because Retry.DEFAULT_ALLOWED_METHODS contains only the idempotent verbs: DELETE, GET, HEAD, OPTIONS, PUT and TRACE. Pass allowed_methods=frozenset({"GET", "POST"}) to include it. Only do that when every POST you send carries an Idempotency-Key, otherwise a retry after a lost response sends the DM a second time.
How do I page past 100 subscribers in Python?
Loop on offset, reading nextOffset and hasMore from each page rather than adding your own increment. limit defaults to 20 and is clamped server-side to a maximum of 100, so asking for more does not fail loudly — it just returns 100 rows and leaves your arithmetic wrong. A generator that yields rows and hides the loop keeps the caller honest.
What makes a good Idempotency-Key for a scheduled job?
Something derived from the work, not from the attempt: a fan id plus the run date, or a row id from your own queue table. Random UUIDs generated per attempt defeat the mechanism entirely, because the retry presents a key the server has never seen. Records expire after 24 hours, so a daily key is replayed correctly within a run and reused safely on the next one.
Does a failed Python request still cost credits?
It depends on the failure. Credits are debited before the handler runs, so 401, 402, 403 and 429 are refused ahead of metering and cost nothing, and a 409, a 424 or any 5xx is debited and then refunded. A 400, 404, 413, 415 or 422 is charged and stays charged — malformed requests are not free, which is another reason to validate ids locally. The cost breakdown has the per-endpoint weights.