ofapisv1
capability

Updated

OnlyFans Fans API

Three surfaces hand you a bare numeric fan id and nothing else: a message.received webhook, a row in the chat list whose withUser is {id}, and the fromUser and toUser on every message. Two routes turn that integer into a person. This page is about those two routes, about the fields they return that cheaper reads do not, and about the one number they will never return — which is why every serious integration ends up keeping its own fan store.

The two lookups

GET /api/public/v1/users/{targetUserId}
GET /api/public/v1/users/by-username/{username}
GET /api/public/v1/accounts/{accountId}/users/{targetUserId}
GET /api/public/v1/accounts/{accountId}/users/by-username/{username}

Neither takes a query parameter. username is the handle without the @. targetUserId is the numeric OnlyFans user id — the same integer that serves as chatId when you read or reply to a DM thread, so a fan id and a conversation id are interchangeable in practice.

The scoped and unscoped forms differ in more than the path. The unscoped routes act through the first account in ACTIVE status on your token and fail with 422 ACCOUNT_NOT_LINKED when there is none. The scoped routes name the account and have three distinct failure modes instead: 404 ACCOUNT_NOT_FOUND when the id is not yours, 409 ACCOUNT_NOT_ACTIVE when it is linked but disconnected, 422 OF_AUTH_INVALID when the stored session needs re-authorising. The full profile field list lives on the creator profile API page; what follows is where those fields come from most cheaply.

One credit each, and the cheaper way round

A per-fan lookup costs one credit and there is no batch form, so enriching 4,000 fans is 4,000 credits — most of a Starter month. Before you spend that, check whether a different read already carries the field you want.

Field /subscribers row List member row /users/{id}
id, username, name, avatar yes yes yes
lastSeen, isBlocked, isRestricted yes yes yes
subscribedBy — they pay the creator no yes yes
subscribedByExpireDate, subscribedByAutoprolong no yes yes
subscribePrice, currentSubscribePrice no yes yes
canReceiveChatMessage no yes yes
tipsEnabled, tipsMin, tipsMax no yes yes
listsStates — membership across other lists no yes yes
joinDate, about, website, location no no yes
postsCount, photosCount, videosCount no no yes
isFriend, canChat, subscribersCount no no yes

The documented subscriber row is {id, username, name, avatar, subscribedOn}, and subscribedOn points the wrong way — it is true when the creator subscribes to them. A list member row, by contrast, is nearly a full profile. Since GET /accounts/{id}/lists/{listId}/users returns up to 100 of those rows for one credit, harvesting a cohort through the lists API costs a hundredth of looping single lookups over the same fans. Reserve /users/{targetUserId} for the fields only it has, or for an id that belongs to no list you maintain.

What no surface returns

There is no spend field anywhere in the public API. Also absent: a purchase count, an unlock history, a message total, a first-subscribed date, tags, internal notes, an email address. subscribedByExpireDate is an expiry, not a tenure; joinDate is when the person signed up to OnlyFans, not to this creator.

So every "top spenders" view is a number somebody accumulated. The only money-adjacent signals the API exposes are the price, isFree and isTip flags on individual messages and whatever appears in the notifications feed — both are observations you have to catch and persist as they happen, which is the subject of the transactions API page. The practical consequence: your fan store's value is a function of how long it has been running, and there is no backfill that fixes a late start.

When a handle moves

Usernames are mutable; ids are not. Key every row on id and treat username as a cached attribute with a seen_at beside it. The by-username route is for human input — a support agent typing a handle into a search box — not for joining records.

For partial or fuzzy input, GET /chats/users?query=... searches name and handle across the people the creator can message, with the usual limit (default 20, clamped 1–100) and offset. It returns candidates, so present them and let a human pick rather than auto-matching the first hit.

Enrichment with a ceiling

The pattern that survives contact with a real roster: never enrich inline, and never enrich without a cap.

import os, requests

BASE = "https://api.ofapis.com/api/public/v1"
S = requests.Session()
S.headers["Authorization"] = f"Bearer {os.environ['OFAPIS_TOKEN']}"

def enrich(account_id, fan_ids, store, budget=250):
    """Fetch profiles for ids the store has never seen, up to `budget` credits."""
    spent, done = 0, []
    for fid in fan_ids:
        if spent >= budget:
            break
        if store.has_profile(fid):
            continue
        r = S.get(f"{BASE}/accounts/{account_id}/users/{fid}", timeout=30)
        spent += 1                       # the 200 is billed; so is a 404
        if r.status_code == 429:
            break                        # free, but stop: the window is exhausted
        if r.status_code in (409, 422, 424):
            raise RuntimeError(f"account {account_id} needs attention: {r.status_code}")
        if r.ok:
            store.upsert(account_id, r.json()["data"])
            done.append(fid)
    return done, spent

Three details earn their lines. A 404 on an unknown user id is charged and not refunded, so a loop that keeps retrying deleted fans burns credits in silence — count it against the budget, as above. A 429 is refused before metering and costs nothing, which makes stopping cheap and retrying free. And 409, 422 or 424 are not about the fan at all; they say the linked account is broken and every subsequent call in the loop will fail the same way, so raise rather than continue. The status-by-status breakdown is on the error reference.

Refresh tiers

Not every field deserves the same clock, and the API gives you no change feed to lean on.

Against the per-token ceiling of 60, 100 or 500 requests per minute by plan, a roster-wide refresh is also a rate limit problem long before it is a credit problem. Enrich shortlists, not populations.

FAQ

Is it cheaper to read one list page or a hundred fan lookups?

The list page, by a factor of a hundred. GET /accounts/{id}/lists/{listId}/users?limit=100 is one credit for up to 100 member rows, and those rows already carry lastSeen, subscribedByExpireDate, the subscription prices and the messaging flags. A single lookup only wins when you need joinDate, the profile text or the content counts.

What happens when a fan changes their username?

Nothing, if you keyed on the numeric id — the id is stable for the life of the account. The old handle simply stops resolving through /users/by-username/{username}, so store the handle as a cached attribute with the time you last confirmed it, and refresh it from whichever read next returns that fan.

Can I find a fan by display name rather than id or handle?

Yes, through GET /chats/users?query=, which does a free-text match on name and handle and pages with limit and offset. It returns a candidate list rather than one record, so it belongs behind a human-facing search box, not inside a matching job.

Does the record tell me how long someone has been subscribed?

No. You get subscribedByExpireDate — when the current subscription lapses — and joinDate, which is when they joined OnlyFans, not this creator. Tenure has to be derived from the first time your own roster sweep saw them, which is one more reason to start snapshotting before you need the number.

Which lookup route should an agency use?

The account-scoped one, always. The unscoped route silently resolves to the first ACTIVE account on the token, so with several creators linked you can read the wrong session and never be told. The scoped form also separates "not your account" (404) from "session expired" (422), which a monitor can act on. Patterns for juggling several are on multi-account management.

Start free · Build a CRM · API reference

Related guides

All guides
Start free — 250 credits, no card
Generate a token and make your first call in minutes.
Get started