How to Build an OnlyFans CRM
One missing field decides the shape of this project. A subscriber row from ofapis is {id, username, name, avatar, subscribedOn}, and subscribedOn is a boolean, not a date. No spend column, no lifetime value, no renewal counter, no last-purchase timestamp. Every top-spender leaderboard and at-risk-fan view you have seen in a CRM screenshot is a number somebody's own database accumulated over weeks.
So if you are the engineer who has to put an OnlyFans CRM behind an agency roster, this is not a sync job mirroring a remote object graph into local tables. It is an append-only event store fed from a few read endpoints plus a log of everything you sent, and its quality is a function of how long it has run. Day one it knows five fields per fan; day ninety it knows who replies, who went quiet, and which message ran before each resubscribe. The endpoint-by-endpoint surface is in the OnlyFans CRM API reference; this page is the build.
Three sources, three reliability classes
Roster snapshots come from GET /subscribers, which takes type (active, expired or all), limit (default 20, clamped 1–100) and offset. It is a point-in-time read: the set today, nothing about yesterday. Detail is on the subscribers API page.
Observed activity comes from GET /chats — each row carries unreadMessagesCount and a lastMessage object with createdAt, text, isTip and isFree — and from GET /chats/{chatId}/messages, which pages backwards on the olderThanMessageId cursor rather than an offset; the messaging API page has the message shape. GET /notifications is head-of-feed only: type and limit, but no offset. If more than limit events land between two polls, the older ones are permanently out of reach. That makes the notifications feed a sampling problem, not a query — poll often and treat what you catch as a lower bound.
Your own writes are the only lossless source. Nothing in the API tells you what you sent last Tuesday. If you did not log it, it did not happen.
The schema
Four tables. fans is current state, fan_snapshots is history, events is everything observed, sends is everything you did.
create table fans (
account_id bigint not null, -- ofapis account, for multi-creator
fan_id bigint not null, -- OnlyFans user id; also the chatId
username text,
name text,
avatar text,
first_seen_at timestamptz not null, -- yours: first snapshot containing them
state text not null, -- active | expired, from the type filter
state_since timestamptz not null, -- yours: when state last changed
last_inbound timestamptz, -- from chats / messages
last_outbound timestamptz, -- from sends
primary key (account_id, fan_id)
);
create table fan_snapshots ( -- one row per fan per sync run
run_id uuid, account_id bigint, fan_id bigint,
state text not null, seen_at timestamptz not null,
primary key (run_id, account_id, fan_id)
);
create table events ( -- append-only, never updated
account_id bigint not null,
fan_id bigint not null,
kind text not null, -- message_in | tip_seen | appeared | lapsed
occurred_at timestamptz not null,
natural_key text not null, -- dedupe key, see below
payload jsonb,
primary key (account_id, natural_key)
);
create table sends ( -- your outbound log
account_id bigint, fan_id bigint,
channel text not null, -- dm | mailing
mailing_run_id uuid, campaign text,
sent_at timestamptz not null,
idempotency_key text not null unique
);
fan_snapshots is what makes the rest work. Churn, reactivation and tenure are set operations between two runs; with only a fans table you can compute none of them, and the API will not tell you after the fact. A fan appearing in a snapshot for the first time is also a usable trigger for welcome-message automation, though the notifications feed spots a new subscriber hours sooner and for a fraction of the credits.
The sync loop
Page with limit=100 — the server clamps anything higher — and follow nextOffset until hasMore is false. Write one snapshot row per fan, then diff. The nightly subscriber sync in Python is this same walk with retries, typed rows and the new-fan diff already wired up.
import time, httpx
BASE = "https://api.ofapis.com/api/public/v1"
H = {"Authorization": "Bearer ofapis_sk_..."}
def walk(account_id, state): # state: active | expired | all
offset, out = 0, []
while True:
r = httpx.get(f"{BASE}/accounts/{account_id}/subscribers", headers=H,
params={"type": state, "limit": 100, "offset": offset})
if r.status_code == 429: # refused before metering, so free to retry
time.sleep(int(r.headers.get("Retry-After", "5")))
continue
r.raise_for_status()
page = r.json()["data"]
out += page["list"]
if not page.get("hasMore"):
return out
offset = page["nextOffset"]
Two traps. First, type is normalised server-side and anything unrecognised silently becomes active — ask for type=lapsed and you get the active set with a 200, your diff shows zero churn, and nothing says you were wrong. Only active, expired and all are real. Second, a 429 is refused before metering, so retrying on Retry-After is free; the per-token ceiling is 60 requests a minute on Free, 100 on Starter and 500 on Pro.
Idempotent ingestion
The roster read has no cursor, so a re-run re-delivers rows you already hold. Make every write idempotent on a natural key instead of trying not to re-read:
- Snapshot rows key on
(run_id, account_id, fan_id), so a re-run under the samerun_idupserts in place; message events key on the stable messageidfrom/chats/{chatId}/messages. - Notification events have no stable id, so synthesise one from
typepluscreatedAtplusfan_idand accept that two genuine events in the same second collapse into one. - Webhook deliveries dedupe on the
X-Ofapis-Deliveryheader, not the body — themessage.receivedpayload carries no event id. The webhooks reference has the signature scheme.
For outbound, send an Idempotency-Key on every POST. A replay returns the original response with Idempotency-Replayed: true and is not charged twice; a concurrent duplicate gets 409 IDEMPOTENCY_CONFLICT. Write the key into sends before issuing the request, so a crash mid-call is recoverable without double-messaging a fan.
What a nightly reconcile costs
A subscriber page is one credit whatever it returns, so the arithmetic is ceil(n / 100) per set.
| Fans in the set | Pages at limit=100 |
Credits per walk | Wall clock at 100 req/min |
|---|---|---|---|
| 2,000 | 20 | 20 | 12s |
| 10,000 | 100 | 100 | 1m |
| 50,000 | 500 | 500 | 5m |
| 200,000 | 2,000 | 2,000 | 20m |
Walk both active and expired and double it. At 50,000 each that is 1,000 credits a night, and Starter's 30,000 monthly credits work out to 1,000 a day — the reconcile alone consumes the plan. The fix is a smaller walk, not a bigger plan: reconcile active nightly and expired weekly, and accept a week's lag on the lapsed set. Weights and plan allowances are on pricing.
Store versus re-fetch
Store what you will compare against its past self: roster membership, message timestamps, your own sends. Re-fetch what is cheap and only useful fresh: unreadMessagesCount, avatars, display names. Per-fan profile reads via GET /users/{targetUserId} cost one credit each — 10,000 fans is 10,000 credits — so reserve them for shortlists, where the extra fields (subscribedByExpireDate, lastSeen, canReceiveChatMessage) change a decision.
Multi-creator
Every read has an account-scoped form under /accounts/{accountId}/..., and the lists and mailings surfaces exist only in that form. Put account_id in every primary key from day one: fan ids are global OnlyFans user ids, the same person can subscribe to two creators on your roster, and merging those relationships into one row is a bug you will not see until a win-back reaches the wrong creator's fan. Both segment surfaces default to limit=10, not 20, so a client that never sends limit silently stores half of every list; segments are covered on fan segments, and the retention logic on this schema is on fan retention.
FAQ
Why can't the CRM just read lifetime spend from the API?
Because the field does not exist. A subscriber record is {id, username, name, avatar, subscribedOn} and subscribedOn is a boolean. The only money-adjacent signals the public API exposes are the isTip and price flags on individual chat messages and whatever surfaces in the notifications feed — both of which you have to observe and persist as they happen.
How long before the CRM is actually useful?
A roster and a chat index are useful on day one for search and bulk messaging. Anything time-derived — tenure, quiet-fan detection, reactivation rate — needs two snapshots to exist at all and about thirty days of them to be stable, because one missed nightly run shows up as a false churn spike.
Should I store the notifications payload verbatim?
Yes. The item is forwarded through unchanged from OnlyFans, its shape is not pinned by the ofapis contract, and you cannot page backwards to re-read it. Write the raw JSON into a jsonb column and parse on read, so an upstream change costs a parser fix instead of a hole in your history.
What happens to credits when a sync call fails?
It depends on the status. 401, 402, 403 and 429 are refused before metering and cost nothing. 409, 424 and 5xx are debited and then refunded — 424 is the expired-OnlyFans-session case, so a reconnect loop is free. But 400, 404, 413, 415 and 422 are charged and not refunded, and 422 covers an account that is not linked as well as invalid OnlyFans credentials, so a loop retrying a deleted account id or a dead link burns credits silently. Fail the job on 404 and 422 instead of retrying.
Can I skip the database and query the API live?
Only for a roster view. A trend, a diff or a "last time we talked" all compare against a past state the API does not keep, and the rate ceilings make per-request fan-out impractical anyway. Build the snapshotter first: Free's 250 credits buy 250 subscriber pages — one walk of a 25,000-fan roster, or ten nightly walks of 2,500 — which is enough to prove the diff before you pay for anything. Start free.