OnlyFans Statistics API
There is no /statistics route on this API. Nothing mirrors the Statistics tab in the OnlyFans web app, nothing returns a rolled-up period, and no endpoint hands back a time series. Every read is current state.
The solution is to snapshot on a schedule, store what you got, and compute periods yourself. Done properly that beats a vendor report, because you control the definitions and the retention. Done carelessly you get a number that silently disagrees with the creator's dashboard and nobody can say why.
What you can read in one call
GET /me is already a five-counter snapshot for one credit:
| Field | Meaning |
|---|---|
subscribersCount |
active subscribers |
subscribesCount |
accounts this creator subscribes to |
postsCount |
posts published |
photosCount |
photos published |
videosCount |
videos published |
curl -s "https://api.ofapis.com/api/public/v1/accounts/42/me" \
-H "Authorization: Bearer ofapis_sk_..." | jq '.data | {subscribersCount, postsCount, videosCount}'
One request, one credit, five headline numbers. If all you need are KPI tiles that tick over daily, stop here: a nightly /me is 30 credits a month per account, and the tiles on an analytics dashboard — or a row appended to a Google Sheet — rarely need more.
The one genuinely dated series sits elsewhere. GET /accounts/{id}/scheduled/posts/counters takes a required publishDate and publishDateEnd plus a timeZone, and returns per-day counts shaped like {"2025-07-01":{"message":3,"post":1}}. That measures output cadence, not audience, and it is the only endpoint that buckets anything by date for you — see the scheduling API.
Why counters are not enough
subscribersCount gives the level. It cannot tell you that you gained 40 and lost 38 on a day it moved by 2. Growth, churn and net adds need identities, and identities come from GET /subscribers.
Here is the constraint that decides your whole schema: a subscriber row carries no join date. The row is an identity plus state flags, and the field people assume is a date — subscribedOn — is a boolean. There is no joinedAt and no expiry on the list row, so you cannot ask "who joined last week" in a single call at any price. The only way to know when someone arrived is to have seen the audience without them and then with them.
So the resolution of every growth metric you report equals your snapshot interval. Snapshot nightly and your churn is a daily number forever; no later query recovers hourly detail. Decide the interval up front, because changing it is not retroactive. Paging mechanics for the sweep live on the subscribers API page.
The storage model
Two tables. One holds the counters, one holds membership.
CREATE TABLE of_counters ( -- one row per /me call
account_id bigint NOT NULL,
taken_at timestamptz NOT NULL,
subscribers integer NOT NULL,
posts integer NOT NULL,
PRIMARY KEY (account_id, taken_at)
);
CREATE TABLE of_membership ( -- one row per fan per observation window
account_id bigint NOT NULL,
fan_id bigint NOT NULL,
first_seen date NOT NULL,
last_seen date NOT NULL, -- advanced on every sweep that still sees them
PRIMARY KEY (account_id, fan_id, first_seen)
);
Do not store a full subscriber list per day: a 50,000-fan roster snapshotted nightly is 18 million rows a year for data that barely changes. The interval form above is one row per fan per membership spell — the nightly job — a few dozen lines of Python on a cron — advances last_seen for ids it still sees, inserts rows for ids it does not have, and leaves departed fans with a stale last_seen. That is 50,000 rows plus churn, and every metric still falls out of it:
-- net adds, joins and lapses for a given day
SELECT
count(*) FILTER (WHERE first_seen = :d) AS joined,
count(*) FILTER (WHERE last_seen = :d - 1) AS lapsed,
count(*) FILTER (WHERE first_seen = :d)
- count(*) FILTER (WHERE last_seen = :d - 1) AS net_adds
FROM of_membership WHERE account_id = :acct;
Keep of_counters as the cheap sanity check: if subscribers from /me and count(*) of live membership rows drift apart, a sweep failed partway and that day's churn should not be published.
Two operational notes. A sweep must be atomic from the reporting job's point of view — half a paged sweep looks exactly like a mass exodus, so write the whole pass or none of it. And type=expired on /subscribers gives the lapsed pool directly, a useful cross-check against the last_seen arithmetic and the input to any win-back segment.
Where your numbers will disagree with the dashboard
They will, and you need the answer ready before a creator asks.
- Boundary definition. Your day is whatever your cron says it is; the OnlyFans dashboard uses its own timezone handling. A nightly 02:00 UTC sweep files a 23:50 local signup under the wrong day.
- Churn inside the gap. A fan who subscribes and cancels between two sweeps is invisible to you and visible to the dashboard;
subscribeditems in the notifications feed catch some of them, but only the newest hundred, and only if you were polling. Trials and short rebill cycles are exactly that population, so your gross adds are a floor and your churn is an undercount. - Expired versus cancelled.
typeoffersactive,expiredandall. "Cancelled but still inside the paid period" is not one of them: that fan is stillactiveto you and may already count as churn upstream. - No money at all. Neither
/menor/subscriberscarries a spend, price or earnings figure. Revenue must be assembled from the monetary fields on chat messages — a different pipeline with different failure modes, covered in revenue analytics.
The honest framing for a client report is that these are your metrics at a stated interval, not a mirror of OnlyFans' own. Put the interval on the report and the disagreements stop being arguments.
Cost of a real collector
One credit per call, and limit is clamped to 1–100, so a sweep is ceil(fans / 100) calls plus one for /me.
| Audience | Calls per nightly sweep | Credits per 30 days |
|---|---|---|
| 1,000 | 11 | 330 |
| 10,000 | 101 | 3,030 |
| 50,000 | 501 | 15,030 |
A 50,000-fan creator swept nightly eats half of Starter's 30,000 monthly credits. Five of them on Pro is 75,150 of 120,000 — it fits, but it leaves thin headroom for messaging, so have your scheduled job sweep the large rosters weekly and the small ones nightly. Check this against pricing and API cost before you schedule it, not after the first 402.
Failures matter because a sweep is long. 429 is refused before metering and costs nothing — back off on Retry-After and resume. 424 and 5xx are debited then refunded. But 400 and 404 are charged and not refunded, so a loop still requesting a deleted accountId burns a credit per page, every night, forever.
FAQ
Which statistics can I get in a single call?
GET /me returns subscribersCount, subscribesCount, postsCount, photosCount and videosCount for one credit, scoped per account. Those are levels, not trends — where the creator is today, with no history and no breakdown behind them.
Can I get subscriber growth for a date range I did not snapshot?
No. The API returns current state only and a subscriber row has no join date, so there is nothing to query retroactively. Growth exists only for periods your collector was already running, which is the single best argument for starting the nightly job before you need the first report.
Why does my subscriber count differ from the OnlyFans Statistics tab?
Usually day boundaries and gap churn. Your figures are bucketed by your cron's clock, and any fan who joined and lapsed between two sweeps never appears in your data at all. Publish the snapshot interval alongside the numbers so the difference is explainable instead of suspicious.
How much storage does a year of snapshots take?
Very little if you store membership intervals rather than daily lists. One row per fan per membership spell means a 50,000-fan creator sits around 50,000 rows plus churn after a year, against roughly 18 million for the naive one-row-per-fan-per-day approach.
Is there any earnings or revenue figure in these endpoints?
Not in /me or /subscribers — neither carries a price, spend or earnings field. Monetary values exist on chat messages (price in cents, isTip, isOpened), so revenue is a separate aggregation job over message history rather than anything the statistics path gives you.