ofapisv1
solution

Updated

OnlyFans Data Export

An onlyfans data export gets you identities and content. It does not get you money, and it does not get you time. No endpoint returns a spend figure, a transaction, a payout line or a subscription start date, so an export built from this API cannot be reconciled against an OnlyFans statement and must never be presented as if it could.

What it does do is move the whole addressable audience, every conversation, the vault index and your own send history into files you control — enough to seed a CRM, back an account up before a migration, or leave a platform with the roster in hand.

What actually leaves

Every row is a successful read at one credit per page. The page key matters more than the field list, because it decides whether a surface can be walked to completion at all.

Surface Route Page key What comes out
Subscriber roster /subscribers?type=active|expired|all offset id, username, name, avatar, subscribedOn
Following /following offset same row shape, different population
Conversation index /chats offset the fan, unreadMessagesCount, a lastMessage object
Message history /chats/{chatId}/messages olderThanMessageId id, text, createdAt, fromUser, isFree, price
Fan lists /accounts/{id}/lists, .../lists/{listId}/users offset, default limit 10 list metadata, then member ids
Vault index /vault/media none — one call returns all of it id, type, isReady, duration, files.full
Scheduled posts /accounts/{id}/scheduled/posts date range only, no offset the queue inside publishDate–publishDateEnd
Mailing runs /accounts/{id}/mailings/{mailingId}/runs none — flat list recipientCount, successCount, skippedCount, creditsDebited, startedAt, completedAt
Notifications /notifications none at all at most the newest 100 items
Mentions /mentions offset release-form mentions, no amounts

Two behave unlike the rest. The vault index takes no query parameters and returns the entire library in one response, making the cheapest export on the list also the most complete. And mailing runs are the only dated history here, because ofapis recorded them rather than OnlyFans — every other timestamp you get is attached to a message.

The scheduled queue is the awkward one. GET /accounts/{id}/scheduled/posts requires publishDate and publishDateEnd, takes a timeZone (default UTC) and a limit clamped to 1–100, and has no offset. A range holding more than 100 posts is truncated with nothing to page with, so read /scheduled/posts/counters for the same range first — per-day counts like {"2025-07-01":{"message":3,"post":1}} — and split the window until every slice fits under 100.

What cannot leave

An export is therefore a census plus a content archive, not a book of account.

The export contains no history, permanently

Everything above is current state. Growth, churn, reactivation and revenue trend are differences between two states, and the API keeps one, so they must be accumulated from your own snapshots going forward — nothing recovers them later at any price. The resolution of every one of those metrics equals your snapshot interval, fixed retroactively: export nightly and churn is a daily number forever, with no finer detail hiding behind it. The interval tables and diffing method are on the statistics page; the point here is that an export started this week is the whole source of next quarter's history.

A resumable walk

Offset paging over a list that mutates while you walk it duplicates and skips. A fan who lapses between page 4 and page 5 shifts every later row down one and you miss someone; a fan who joins at the head pushes a row you already hold into the next page and you read them twice. Neither is visible in the response. There is no cleverer cursor — upsert by id and treat your own row count as approximate.

import csv, json, os, pathlib, time, httpx

BASE = "https://api.ofapis.com/api/public/v1"
H = {"Authorization": f"Bearer {os.environ['OFAPIS_TOKEN']}"}
CKPT = pathlib.Path("export.ckpt")          # survives a crash, a 402, a deploy

def export(account_id, kind="all", out="fans.csv"):
    offset = json.loads(CKPT.read_text())["offset"] if CKPT.exists() else 0
    rows = {}                                # id -> row, so a re-read overwrites
    with httpx.Client(timeout=30) as c:
        while True:
            r = c.get(f"{BASE}/accounts/{account_id}/subscribers", headers=H,
                      params={"type": kind, "limit": 100, "offset": offset})
            if r.status_code == 429:         # refused before metering: free to retry
                time.sleep(int(r.headers.get("Retry-After", "5")))
                continue
            if r.status_code in (404, 422):  # charged, and retrying stays charged
                raise SystemExit(f"stop: {r.status_code} {r.text}")
            r.raise_for_status()
            body = r.json()["data"]
            for f in body["list"]:
                rows[f["id"]] = f
            if not body["hasMore"] or not body["list"]:
                break
            offset += 100
            CKPT.write_text(json.dumps({"offset": offset}))

    with open(out, "w", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["id", "username", "name", "avatar", "subscribed_flag"])
        for f in rows.values():
            w.writerow([f["id"], f.get("username"), f.get("name"),
                         f.get("avatar"), bool(f.get("subscribedOn"))])
    CKPT.unlink(missing_ok=True)
    return len(rows)

Five columns, and subscribed_flag is a boolean because the field is one. Those are the real names; a spend or joined_at column has nothing to fill it from. If the destination is a sheet rather than a file, the Apps Script version writes the same five columns and Airtable upserts them into records instead.

What a full pass costs

limit=100 is the clamp and always right for an export: one credit buys 100 rows or 20 rows identically, so a pass is ceil(rows / 100) credits.

Roster Pages per pass Credits, active + expired Share of a month
2,000 20 40 16% of Free's 250
20,000 200 400 1.3% of Starter's 30,000
200,000 2,000 4,000 3.3% of Pro's 120,000

A 200,000-row export does not fit Free's allowance at all, and credits do not roll over, so a one-off migration on Free splits across months or runs on a paid plan for a single cycle. Pace it under the per-token ceiling — 60 requests a minute on Free, 100 on Starter, 500 on Pro. A 422 for an unlinked account and a 404 for an id not on your token are both charged and not refunded, which is why the loop exits on them instead of retrying. Pricing has the allowances.

Message history, on the cursor

Conversations do not use offsets. GET /chats/{chatId}/messages takes limit and olderThanMessageId, returns newest-first, and exposes nextMessageId — the oldest id in the page you just read — to feed the next call.

def dump_chat(c, account_id, chat_id, w):
    cursor, n = None, 0
    while True:
        p = {"limit": 100} | ({"olderThanMessageId": cursor} if cursor else {})
        r = c.get(f"{BASE}/accounts/{account_id}/chats/{chat_id}/messages",
                  headers=H, params=p)
        r.raise_for_status()
        d = r.json()["data"]
        for m in d["list"]:
            w.writerow([chat_id, m["id"], m["createdAt"], m.get("price", 0),
                        m.get("isFree"), (m.get("text") or "").replace("\n", " ")])
            n += 1
        if not d["hasMore"] or not d.get("nextMessageId"):
            return n
        cursor = d["nextMessageId"]

Budget it per conversation: one credit per 100 messages. A 3,000-conversation inbox averaging 60 messages each is 30 /chats pages plus roughly 1,800 message pages — fine as a one-time archive, ruinous as a nightly recompute. Archive once, then keep a watermark and read only what is newer.

Several creators, one token

Every read has an account-scoped form under /accounts/{accountId}/..., and the lists, mailings and scheduled surfaces exist only in that form. Export per account and put account_id in the filename and in every row: fan ids are global OnlyFans user ids, so one person subscribed to two creators on your roster appears in both files under the same id, and concatenating without that column merges two relationships into one. One linked account on Free and Starter, five on Pro. Storing the merged result belongs to the CRM build, the paging edges of the roster read to the subscribers API page.

Once it is out, it is your problem

The file is other people's personal data: handles, display names, avatar URLs and, if you exported conversations, the text of private messages. Decide a retention window before the first run and enforce it with a delete job — an export with no expiry quietly becomes the largest liability in the business. Keep it encrypted and out of shared drives, chat uploads and anything a departing contractor retains access to; a roster CSV is worth more to a competitor than the token that produced it. And be able to delete one person on request, which means keying files on id rather than appending dated dumps you would have to rewrite. Whose responsibility the account relationship is is worth reading alongside this, and the guides index covers what to build once the data lands.

FAQ

Can I export my OnlyFans earnings or payout history?

No. There is no earnings, transactions or payout endpoint, and no record carries a spend or lifetime-value figure. The only monetary values are price and isTip on individual DMs, which say what was asked for rather than what was paid, so nothing assembled from these reads ties to an OnlyFans statement.

Can I get each fan's subscription date into the export?

Not from the roster read. subscribedOn is a boolean state flag and no list row has a join-date field. A per-fan GET /users/{targetUserId} does return joinDate and subscribedByExpireDate, but it costs one credit per fan — 20,000 fans is 20,000 credits — so enrich a shortlist, never a whole export.

Why do two exports an hour apart have different row counts?

Because the list mutates while you page it and no response carries a total to check against. Fans joining at the head push rows into later pages and lapsing fans pull them forward, so a long walk over a busy roster legitimately lands a few rows out either way. Treat the count as approximate and the set of ids as authoritative.

Can I export notifications older than the newest 100?

No. /notifications accepts only type and limit, with no offset and no cursor, so there is no backfill mechanism at all. Whatever is in the newest 100 items when you first poll is the oldest activity history you will ever have, which is the strongest argument for starting a collector before you need the data.

Does a failed export page still cost credits?

It depends on the status. Credits are debited before the handler runs: 401, 402, 403 and 429 are refused by the gateway and cost nothing, while 409, 424 and 5xx are debited then refunded. But 400, 404, 413, 415 and 422 are charged and not refunded, so a loop grinding against a deleted account id burns a credit per attempt. Error handling has the full mapping, and a free token is enough to size your own roster first.

Related guides

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