OnlyFans API SDK
Nothing to install. OnlyFans ships no client library because it ships no public API, and ofapis publishes no vendor package either — no npm scope, no PyPI distribution, no Maven coordinate. If you came here to pip install something, that is the answer, and the rest of this page is why it costs you almost nothing.
What exists instead is an OpenAPI 3.0 document, regenerated from the live controllers on every deploy. From it you can emit a client in one command, or type by hand the four or five shapes you actually touch. Both take under an hour; this page measures which to pick.
Getting the document
Two URLs serve it. https://api.ofapis.com/v3/api-docs/public is canonical, straight from springdoc. https://ofapis.com/docs/openapi is a same-origin proxy with an hour of caching behind it, which is what browser tooling wants because it needs no CORS preflight. Both are scoped to the public group, so the file covers /api/public/** and nothing internal — the reading surfaces and the playground are on the API documentation page.
One trap belongs to the proxy. When the backend is unreachable it does not fail: it answers 200 with a syntactically valid placeholder whose paths object is empty. That is right for a renderer and wrong for a build step — a generator pointed at the proxy during a blip emits a compiling, publishable client with zero methods in it. Generate from the upstream URL, or assert the path count before handing the file to anything.
What the document actually contains
Forty-seven operations across twelve tags. Thirty-nine carry an explicit operationId; the eight under Mailings carry none. That split decides a lot, because the two halves fail in opposite directions.
| 39 operations | 8 Mailings operations | |
|---|---|---|
| Method name you get | The hand-written operationId |
Derived from the controller method |
data in the 200 schema |
Untyped — object |
A real named model, on seven of the eight |
| Shared 401/402/403/429 errors | Attached by annotation | Absent |
| Rate-limit headers on 200 | Documented | Absent |
The operationIds follow one rule worth learning first, because they become your method names verbatim. Every capability that can address a specific creator appears twice: a bare name, and the same name with Scoped appended. getSubscribers hits the legacy route that falls back to your first active linked account; getSubscribersScoped takes an accountId path parameter and names that creator. Thirteen such pairs exist, covering the reads, both user lookups and the send. Either half works for one creator; if you run several, delete the unscoped half from your wrapper so nobody reaches for it by accident. The other thirteen — uploadMedia, six under Lists, six under Scheduled — are already account-addressed and have no twin.
Route one: generate it
npx @openapitools/openapi-generator-cli generate \
-g python \
-i https://api.ofapis.com/v3/api-docs/public \
-o ./ofapis \
--additional-properties=packageName=ofapis,library=urllib3
You get forty-seven methods, every path and query parameter, and the credential as one constructor argument — the document declares a single HTTP bearer scheme named ApiToken and no OAuth flows. That part is free and worth having: paths and parameter names are where hand-written clients rot.
Now the caveat you cannot see by skimming the spec in a renderer. Forty of the forty-seven handlers declare their return as the envelope wrapping an untyped payload, so the generated model hands you data as object, Any, interface{} or JsonNode — precisely the type you generated a client to avoid. The real field names are present, but as examples on each 200 response, which no generator turns into a class. The seven that are typed are all Mailings, which is the inverse problem: proper models, reached through method names you did not choose.
Two smaller mismatches come from the same annotations. No numeric bounds are declared anywhere: limit is an integer with a default, and the 1–100 range lives only in the parameter's description prose, so a generated client will happily send limit=5000. And the error model is half true — PublicApiErrorEnvelope is a real component, correct for the five statuses the gateway itself produces, but anything raised inside a handler arrives as a bare string with no code field, so deserializing it into the generated class throws a parse error on top of the failure you were reading. One annotation is also simply wrong: thirteen operations document ACCOUNT_NOT_LINKED as 424 where the handler returns 422, so never key refund logic on it. The catalogue is in error handling.
So: generate, keep the request side, discard the response models, write the few payload types yourself. If that sounds like most of the work, it is — which is the argument for route two.
Route two: write it yourself
No signing, no token exchange, no second credential, no envelope that varies by endpoint: one Authorization header, one {"data": ...} wrapper, forty-seven routes. The whole client is the part that unwraps the envelope, classifies the failure and reads the headers:
const BASE = "https://api.ofapis.com/api/public/v1";
// Debited before the handler runs and not refunded, so retrying only bills again.
const CHARGED = new Set([400, 404, 413, 415, 422]);
export class OfapisError extends Error {
constructor(readonly status: number, readonly code: string | null, msg: string) { super(msg); }
get worthRetrying() { return !CHARGED.has(this.status); }
}
// hasMore is a hint, not a contract, and nextOffset defaults to 0: page by your own limit.
export type Page<T> = { list: T[]; hasMore: boolean; nextOffset: number };
export class Ofapis {
budget: { limit: number; remaining: number; reset: number } | null = null;
constructor(private token: string) {}
async call<T>(method: string, path: string, o: { body?: unknown; key?: string } = {}): Promise<T> {
const res = await fetch(BASE + path, {
method,
headers: {
authorization: `Bearer ${this.token}`,
...(o.body ? { "content-type": "application/json" } : {}),
...(o.key ? { "idempotency-key": o.key } : {}),
},
body: o.body ? JSON.stringify(o.body) : undefined,
});
const h = res.headers;
if (h.has("x-ratelimit-remaining")) {
this.budget = {
limit: +h.get("x-ratelimit-limit")!,
remaining: +h.get("x-ratelimit-remaining")!,
reset: +h.get("x-ratelimit-reset")!,
};
}
const raw = await res.text(); // a handler error is a bare string, not JSON
if (!res.ok) {
try {
const j = JSON.parse(raw);
throw new OfapisError(res.status, j.error?.code ?? null, j.error?.message ?? raw);
} catch (e) {
if (e instanceof OfapisError) throw e;
throw new OfapisError(res.status, null, raw);
}
}
return JSON.parse(raw).data as T;
}
}
Types go at the call site, where you can see which fields you use:
type Subscriber = { id: number; username: string; name: string; avatar: string; subscribedOn: boolean };
const api = new Ofapis(process.env.OFAPIS_TOKEN!);
const fans = await api.call<Page<Subscriber>>("GET", "/accounts/42/subscribers?limit=100");
await api.call("POST", "/accounts/42/chats/98765/messages",
{ body: { text: "hi", price: 0 }, key: "welcome:98765" });
That subscribedOn is a boolean rather than a date is something no generated model would have told you either. Idiomatic versions of this core exist for nine languages in the quickstarts: Python mounts a retry policy under it, Go and Rust type the envelope with generics.
What any client has to get right
Generated or not, a wrapper either encodes these or leaks them to its callers.
| Behaviour | What to encode |
|---|---|
limit bounds |
Clamp to 1–100 yourself; the spec declares no min or max |
| Default page size | 20, except the two Lists routes where it is 10 |
| Message history | Cursor only: pass olderThanMessageId, never offset |
/notifications |
Takes type and limit alone — a head window with no backfill |
| Loop termination | No total field, and hasMore is hard-coded false on one upstream payload shape — advance by your own limit and stop on a short page |
| Idempotency keys | Scoped to (token, key), not the path — never reuse one across endpoints |
| Charged failures | Surface 400, 404, 413, 415 and 422 as terminal, not as retry candidates |
Pagination and idempotency bite hardest in a generated client, which reproduces the parameter list faithfully and encodes none of the semantics. Pacing belongs in the wrapper too, against the fixed 60-second window described in rate limits.
Versioning, and what moves under you
info.version reads 1.0 and has never moved; the real version marker is the /v1 in the path prefix. Because the spec is derived from annotations at runtime, it changes the moment a deploy changes a controller, with nothing incrementing to tell you and no changelog feed for the document.
So treat it as a dependency: commit the JSON beside your generated output and diff it in CI, fetched upstream rather than through the cached proxy so a blip cannot quietly empty the diff. A new path is additive and harmless; a renamed operationId renames one of your methods, and a parameter moving from optional to required breaks a call. A hand-written client gets that protection free, because it only references what you typed.
The third option: no client at all
If your integration is a handful of calls inside an assistant or a workflow tool, skip both routes. The MCP server exposes the same operations to a coding agent, which beats letting one invent paths against an API where a plausible-looking route often does not exist. Task walkthroughs are indexed at /guides.
FAQ
Is there an official OnlyFans API SDK?
No. OnlyFans publishes no public API, so no first-party client library exists in any language and there is no OnlyFans-issued key to put in one. ofapis is an independent REST layer that maintains the integration instead, and it publishes an OpenAPI 3.0 document rather than a package — so you are never waiting on a vendor to ship a binding for your language.
Should I generate a client or write one?
Generate if you want the request side guaranteed correct across many endpoints, or if your language's generator output is pleasant to read. Write one if you touch fewer than a dozen routes, because the generated response models give you an untyped payload on forty of the forty-seven operations and you will declare those types by hand regardless. The middle path is to generate once, keep the paths and parameters, and replace the models.
Why is data untyped in the generated models?
Most handlers return the envelope with a loosely typed payload, so the schema the generator sees for data is an empty object schema. The field names do exist, in each operation's 200 examples, which generators ignore. Mailings is the exception and does produce real models, because those handlers declare concrete response types.
Will a generated client validate my parameters?
Only presence and type, not range. No minimum or maximum is declared on any parameter, so limit=5000 passes client-side validation and is clamped to 100 by the server — you get a 100-item page and no warning. Encode the 1–100 bound yourself, along with the limit-10 default on the Lists routes.
How do I handle errors in a client I generated?
Do not deserialize every failure into the generated error model. The {"error":{"code","message"}} shape is real for the gateway's refusals at 401, 402, 403, 429 and the idempotency 409, and branching on code is safe there. Anything raised inside a handler is a plain string body, so read the response as text first and attempt the JSON parse second, which is what the core above does.
Can I explore the API without generating anything?
Yes, and for exploratory work it is the better move: import either spec URL into Postman or an IDE HTTP client and you have every route with its parameters filled in, no build step. The playground on /docs goes further and runs calls against your own linked accounts, metered at normal weight.